diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 45854515cdf7ba53705841701033749249052611..dea9cbbe0faa08a6462a382bd21a48b32a17b581 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -960,3 +960,5 @@ MIN_DELAY_BETWEEN_DOWNLOADS_COUNT = env.int(
     "MIN_DELAY_BETWEEN_DOWNLOADS_COUNT", default=60 * 60 * 6
 )
 MARKDOWN_EXTENSIONS = env.list("MARKDOWN_EXTENSIONS", default=["nl2br", "extra"])
+
+LINKIFIER_SUPPORTED_TLDS = ["audio"] + env.list("LINKINFIER_SUPPORTED_TLDS", default=[])
diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py
index 19aefa2ed753bf5375b779ae93ed2c87582fd064..730fcc298c7225e1ed7c2b55afff1aa152864a8d 100644
--- a/api/config/spa_urls.py
+++ b/api/config/spa_urls.py
@@ -23,7 +23,12 @@ urlpatterns = [
     ),
     urls.re_path(
         r"^channels/(?P<uuid>[0-9a-f-]+)/?$",
-        audio_spa_views.channel_detail,
+        audio_spa_views.channel_detail_uuid,
+        name="channel_detail",
+    ),
+    urls.re_path(
+        r"^channels/(?P<username>[^/]+)/?$",
+        audio_spa_views.channel_detail_username,
         name="channel_detail",
     ),
 ]
diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py
index e95bb5d66ff0b3b3987ecf6c9a5389c746fb50aa..30811383628a08a8aa62f0ff431b6561fdc3aec6 100644
--- a/api/funkwhale_api/audio/models.py
+++ b/api/funkwhale_api/audio/models.py
@@ -6,6 +6,8 @@ from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 from django.urls import reverse
 from django.utils import timezone
+from django.db.models.signals import post_delete
+from django.dispatch import receiver
 
 from funkwhale_api.federation import keys
 from funkwhale_api.federation import models as federation_models
@@ -44,14 +46,22 @@ class Channel(models.Model):
     )
 
     def get_absolute_url(self):
-        return federation_utils.full_url("/channels/{}".format(self.uuid))
+        suffix = self.uuid
+        if self.actor.is_local:
+            suffix = self.actor.preferred_username
+        else:
+            suffix = self.actor.full_username
+        return federation_utils.full_url("/channels/{}".format(suffix))
 
     def get_rss_url(self):
         if not self.artist.is_local:
             return self.rss_url
 
         return federation_utils.full_url(
-            reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid})
+            reverse(
+                "api:v1:channels-rss",
+                kwargs={"composite": self.actor.preferred_username},
+            )
         )
 
 
@@ -62,3 +72,10 @@ def generate_actor(username, **kwargs):
     actor_data["public_key"] = public.decode("utf-8")
 
     return federation_models.Actor.objects.create(**actor_data)
+
+
+@receiver(post_delete, sender=Channel)
+def delete_channel_related_objs(instance, **kwargs):
+    instance.library.delete()
+    instance.actor.delete()
+    instance.artist.delete()
diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py
index df98bde3263a171ba94b6a156e46224f67938205..a162ae9c041722f4a7bceef90ba951d3d8a62a98 100644
--- a/api/funkwhale_api/audio/serializers.py
+++ b/api/funkwhale_api/audio/serializers.py
@@ -3,6 +3,8 @@ from django.db import transaction
 
 from rest_framework import serializers
 
+from django.contrib.staticfiles.templatetags.staticfiles import static
+
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.common import utils as common_utils
 from funkwhale_api.common import locales
@@ -24,7 +26,7 @@ class ChannelMetadataSerializer(serializers.Serializer):
     itunes_category = serializers.ChoiceField(
         choices=categories.ITUNES_CATEGORIES, required=True
     )
-    itunes_subcategory = serializers.CharField(required=False)
+    itunes_subcategory = serializers.CharField(required=False, allow_null=True)
     language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES)
     copyright = serializers.CharField(required=False, allow_null=True, max_length=255)
     owner_name = serializers.CharField(required=False, allow_null=True, max_length=255)
@@ -64,6 +66,7 @@ class ChannelCreateSerializer(serializers.Serializer):
         choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
     )
     metadata = serializers.DictField(required=False)
+    cover = music_serializers.COVER_WRITE_FIELD
 
     def validate(self, validated_data):
         existing_channels = self.context["actor"].owned_channels.count()
@@ -95,15 +98,15 @@ class ChannelCreateSerializer(serializers.Serializer):
     def create(self, validated_data):
         from . import views
 
+        cover = validated_data.pop("cover", None)
         description = validated_data.get("description")
         artist = music_models.Artist.objects.create(
             attributed_to=validated_data["attributed_to"],
             name=validated_data["name"],
             content_category=validated_data["content_category"],
+            attachment_cover=cover,
         )
-        description_obj = common_utils.attach_content(
-            artist, "description", description
-        )
+        common_utils.attach_content(artist, "description", description)
 
         if validated_data.get("tags", []):
             tags_models.set_tags(artist, *validated_data["tags"])
@@ -113,9 +116,8 @@ class ChannelCreateSerializer(serializers.Serializer):
             attributed_to=validated_data["attributed_to"],
             metadata=validated_data["metadata"],
         )
-        summary = description_obj.rendered if description_obj else None
         channel.actor = models.generate_actor(
-            validated_data["username"], summary=summary, name=validated_data["name"],
+            validated_data["username"], name=validated_data["name"],
         )
 
         channel.library = music_models.Library.objects.create(
@@ -142,6 +144,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
         choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
     )
     metadata = serializers.DictField(required=False)
+    cover = music_serializers.COVER_WRITE_FIELD
 
     def validate(self, validated_data):
         validated_data = super().validate(validated_data)
@@ -194,6 +197,9 @@ class ChannelUpdateSerializer(serializers.Serializer):
                 ("content_category", validated_data["content_category"])
             )
 
+        if "cover" in validated_data:
+            artist_update_fields.append(("attachment_cover", validated_data["cover"]))
+
         if actor_update_fields:
             for field, value in actor_update_fields:
                 setattr(obj.actor, field, value)
@@ -292,7 +298,7 @@ def rss_serialize_item(upload):
                 # we enforce MP3, since it's the only format supported everywhere
                 "url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
                 "length": upload.size or 0,
-                "type": upload.mimetype or "audio/mpeg",
+                "type": "audio/mpeg",
             }
         ],
     }
@@ -362,6 +368,11 @@ def rss_serialize_channel(channel):
         data["itunes:image"] = [
             {"href": channel.artist.attachment_cover.download_url_original}
         ]
+    else:
+        placeholder_url = federation_utils.full_url(
+            static("images/podcasts-cover-placeholder.png")
+        )
+        data["itunes:image"] = [{"href": placeholder_url}]
 
     tagged_items = getattr(channel.artist, "_prefetched_tagged_items", [])
 
diff --git a/api/funkwhale_api/audio/spa_views.py b/api/funkwhale_api/audio/spa_views.py
index 097e00cf43828dd9b08b9243b87b8d275610f744..c76669d39ece58f31983d89deaaf2be35ed92184 100644
--- a/api/funkwhale_api/audio/spa_views.py
+++ b/api/funkwhale_api/audio/spa_views.py
@@ -1,26 +1,33 @@
 import urllib.parse
 
 from django.conf import settings
+from django.db.models import Q
 from django.urls import reverse
 
+from rest_framework import serializers
+
 from funkwhale_api.common import preferences
 from funkwhale_api.common import utils
+from funkwhale_api.federation import utils as federation_utils
 from funkwhale_api.music import spa_views
 
 from . import models
 
 
-def channel_detail(request, uuid):
-    queryset = models.Channel.objects.filter(uuid=uuid).select_related(
+def channel_detail(query):
+    queryset = models.Channel.objects.filter(query).select_related(
         "artist__attachment_cover", "actor", "library"
     )
     try:
         obj = queryset.get()
     except models.Channel.DoesNotExist:
         return []
+
     obj_url = utils.join_url(
         settings.FUNKWHALE_URL,
-        utils.spa_reverse("channel_detail", kwargs={"uuid": obj.uuid}),
+        utils.spa_reverse(
+            "channel_detail", kwargs={"username": obj.actor.full_username}
+        ),
     )
     metas = [
         {"tag": "meta", "property": "og:url", "content": obj_url},
@@ -72,3 +79,25 @@ def channel_detail(request, uuid):
         # twitter player is also supported in various software
         metas += spa_views.get_twitter_card_metas(type="channel", id=obj.uuid)
     return metas
+
+
+def channel_detail_uuid(request, uuid):
+    validator = serializers.UUIDField().to_internal_value
+    try:
+        uuid = validator(uuid)
+    except serializers.ValidationError:
+        return []
+    return channel_detail(Q(uuid=uuid))
+
+
+def channel_detail_username(request, username):
+    validator = federation_utils.get_actor_data_from_username
+    try:
+        username_data = validator(username)
+    except serializers.ValidationError:
+        return []
+    query = Q(
+        actor__domain=username_data["domain"],
+        actor__preferred_username__iexact=username_data["username"],
+    )
+    return channel_detail(query)
diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py
index 6d463d3693650fda9c56a842a67dd27a1ac1358f..974797c353809993f082d6ce43ff9f0089b02213 100644
--- a/api/funkwhale_api/audio/views.py
+++ b/api/funkwhale_api/audio/views.py
@@ -7,18 +7,21 @@ from rest_framework import viewsets
 
 from django import http
 from django.db import transaction
-from django.db.models import Count, Prefetch
+from django.db.models import Count, Prefetch, Q
 from django.db.utils import IntegrityError
 
+from funkwhale_api.common import locales
 from funkwhale_api.common import permissions
 from funkwhale_api.common import preferences
+from funkwhale_api.common.mixins import MultipleLookupDetailMixin
 from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import routes
+from funkwhale_api.federation import utils as federation_utils
 from funkwhale_api.music import models as music_models
 from funkwhale_api.music import views as music_views
 from funkwhale_api.users.oauth import permissions as oauth_permissions
 
-from . import filters, models, renderers, serializers
+from . import categories, filters, models, renderers, serializers
 
 ARTIST_PREFETCH_QS = (
     music_models.Artist.objects.select_related("description", "attachment_cover",)
@@ -36,6 +39,7 @@ class ChannelsMixin(object):
 
 class ChannelViewSet(
     ChannelsMixin,
+    MultipleLookupDetailMixin,
     mixins.CreateModelMixin,
     mixins.RetrieveModelMixin,
     mixins.UpdateModelMixin,
@@ -43,7 +47,20 @@ class ChannelViewSet(
     mixins.DestroyModelMixin,
     viewsets.GenericViewSet,
 ):
-    lookup_field = "uuid"
+    url_lookups = [
+        {
+            "lookup_field": "uuid",
+            "validator": serializers.serializers.UUIDField().to_internal_value,
+        },
+        {
+            "lookup_field": "username",
+            "validator": federation_utils.get_actor_data_from_username,
+            "get_query": lambda v: Q(
+                actor__domain=v["domain"],
+                actor__preferred_username__iexact=v["username"],
+            ),
+        },
+    ]
     filterset_class = filters.ChannelFilter
     serializer_class = serializers.ChannelSerializer
     queryset = (
@@ -134,6 +151,25 @@ class ChannelViewSet(
         data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
         return response.Response(data, status=200)
 
+    @decorators.action(
+        methods=["get"],
+        detail=False,
+        url_path="metadata-choices",
+        url_name="metadata_choices",
+        permission_classes=[],
+    )
+    def metedata_choices(self, request, *args, **kwargs):
+        data = {
+            "language": [
+                {"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
+            ],
+            "itunes_category": [
+                {"value": code, "label": code, "children": children}
+                for code, children in categories.ITUNES_CATEGORIES.items()
+            ],
+        }
+        return response.Response(data)
+
     def get_serializer_context(self):
         context = super().get_serializer_context()
         context["subscriptions_count"] = self.action in [
@@ -152,7 +188,7 @@ class ChannelViewSet(
             {"type": "Delete", "object": {"type": instance.actor.type}},
             context={"actor": instance.actor},
         )
-        instance.delete()
+        instance.__class__.objects.filter(pk=instance.pk).delete()
 
 
 class SubscriptionsViewSet(
diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py
index 9e2a59dca25d289953dff7b32de0ddba8a0745ef..6122adbcab26c91a0657494a8c2ee7a25db55831 100644
--- a/api/funkwhale_api/common/middleware.py
+++ b/api/funkwhale_api/common/middleware.py
@@ -1,4 +1,5 @@
 import html
+import logging
 import io
 import os
 import re
@@ -20,6 +21,8 @@ from . import utils
 
 EXCLUDED_PATHS = ["/api", "/federation", "/.well-known"]
 
+logger = logging.getLogger(__name__)
+
 
 def should_fallback_to_spa(path):
     if path == "/":
@@ -270,6 +273,17 @@ class ThrottleStatusMiddleware:
         return response
 
 
+class VerboseBadRequestsMiddleware:
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        response = self.get_response(request)
+        if response.status_code == 400:
+            logger.warning("Bad request: %s", response.content)
+        return response
+
+
 class ProfilerMiddleware:
     """
     from https://github.com/omarish/django-cprofile-middleware/blob/master/django_cprofile_middleware/middleware.py
diff --git a/api/funkwhale_api/common/mixins.py b/api/funkwhale_api/common/mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed619d637ce39f2d623c05a232c9a520c7e144b3
--- /dev/null
+++ b/api/funkwhale_api/common/mixins.py
@@ -0,0 +1,34 @@
+from rest_framework import serializers
+
+from django.db.models import Q
+from django.shortcuts import get_object_or_404
+
+
+class MultipleLookupDetailMixin(object):
+    lookup_value_regex = "[^/]+"
+    lookup_field = "composite"
+
+    def get_object(self):
+        queryset = self.filter_queryset(self.get_queryset())
+
+        relevant_lookup = None
+        value = None
+        for lookup in self.url_lookups:
+            field_validator = lookup["validator"]
+            try:
+                value = field_validator(self.kwargs["composite"])
+            except serializers.ValidationError:
+                continue
+            else:
+                relevant_lookup = lookup
+                break
+        get_query = relevant_lookup.get(
+            "get_query", lambda value: Q(**{relevant_lookup["lookup_field"]: value})
+        )
+        query = get_query(value)
+        obj = get_object_or_404(queryset, query)
+
+        # May raise a permission denied
+        self.check_object_permissions(self.request, obj)
+
+        return obj
diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py
index ec2c1d1f83975210d0e2698ecf40ad96622c33ac..35f64406e296cc7a54ab7098127c067a70847567 100644
--- a/api/funkwhale_api/common/models.py
+++ b/api/funkwhale_api/common/models.py
@@ -359,4 +359,7 @@ def remove_attached_content(sender, instance, **kwargs):
     fk_fields = CONTENT_FKS.get(instance._meta.label, [])
     for field in fk_fields:
         if getattr(instance, "{}_id".format(field)):
-            getattr(instance, field).delete()
+            try:
+                getattr(instance, field).delete()
+            except Content.DoesNotExist:
+                pass
diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index 7af83be94d5d185911a32f6de1d7dfad9d831625..b36d1cd641be1b5b88e6eabd7c409e71739a16a5 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -279,7 +279,11 @@ HTML_PERMISSIVE_CLEANER = bleach.sanitizer.Cleaner(
     attributes=["class", "rel", "alt", "title"],
 )
 
-HTML_LINKER = bleach.linkifier.Linker()
+# support for additional tlds
+# cf https://github.com/mozilla/bleach/issues/367#issuecomment-384631867
+ALL_TLDS = set(settings.LINKIFIER_SUPPORTED_TLDS + bleach.linkifier.TLDS)
+URL_RE = bleach.linkifier.build_url_re(tlds=sorted(ALL_TLDS, reverse=True))
+HTML_LINKER = bleach.linkifier.Linker(url_re=URL_RE)
 
 
 def clean_html(html, permissive=False):
@@ -338,29 +342,34 @@ def attach_file(obj, field, file_data, fetch=False):
     if not file_data:
         return
 
-    extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
-    extension = extensions.get(file_data["mimetype"], "jpg")
-    attachment = models.Attachment(mimetype=file_data["mimetype"])
-    name_fields = ["uuid", "full_username", "pk"]
-    name = [getattr(obj, field) for field in name_fields if getattr(obj, field, None)][
-        0
-    ]
-    filename = "{}-{}.{}".format(field, name, extension)
-    if "url" in file_data:
-        attachment.url = file_data["url"]
+    if isinstance(file_data, models.Attachment):
+        attachment = file_data
     else:
-        f = ContentFile(file_data["content"])
-        attachment.file.save(filename, f, save=False)
-
-    if not attachment.file and fetch:
-        try:
-            tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
-        except Exception as e:
-            logger.warn("Cannot download attachment at url %s: %s", attachment.url, e)
-            attachment = None
-
-    if attachment:
-        attachment.save()
+        extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
+        extension = extensions.get(file_data["mimetype"], "jpg")
+        attachment = models.Attachment(mimetype=file_data["mimetype"])
+        name_fields = ["uuid", "full_username", "pk"]
+        name = [
+            getattr(obj, field) for field in name_fields if getattr(obj, field, None)
+        ][0]
+        filename = "{}-{}.{}".format(field, name, extension)
+        if "url" in file_data:
+            attachment.url = file_data["url"]
+        else:
+            f = ContentFile(file_data["content"])
+            attachment.file.save(filename, f, save=False)
+
+        if not attachment.file and fetch:
+            try:
+                tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
+            except Exception as e:
+                logger.warn(
+                    "Cannot download attachment at url %s: %s", attachment.url, e
+                )
+                attachment = None
+
+        if attachment:
+            attachment.save()
 
     setattr(obj, field, attachment)
     obj.save(update_fields=[field])
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index c200e4f6efd6e70f9521bea38707d4c19153a264..820c93bae1d55fce1a63fe95f5d0f84f9c953653 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -246,6 +246,8 @@ class Actor(models.Model):
         return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
 
     def should_autoapprove_follow(self, actor):
+        if self.get_channel():
+            return True
         return False
 
     def get_user(self):
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 351e1ac79a14808ee7d40f4d63a530b0b21dd543..2e769f38a64b75b9cd2f2423d6b355c7b0435acc 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -134,7 +134,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
     )
     preferredUsername = serializers.CharField()
     manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
-    name = serializers.CharField(required=False, max_length=200)
+    name = serializers.CharField(
+        required=False, max_length=200, allow_blank=True, allow_null=True
+    )
     summary = TruncatedCharField(
         truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
         required=False,
@@ -209,6 +211,8 @@ class ActorSerializer(jsonld.JsonLdSerializer):
                 },
             ]
             include_image(ret, channel.artist.attachment_cover, "icon")
+            if channel.artist.description_id:
+                ret["summary"] = channel.artist.description.rendered
         else:
             ret["url"] = [
                 {
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 11840e52c4c57243e62b4d95e9b1a977ab380e9f..c20d792bc547069e24569d73cf186849b256859c 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -71,7 +71,7 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
     @action(methods=["get", "post"], detail=True)
     def outbox(self, request, *args, **kwargs):
         actor = self.get_object()
-        channel = actor.channel
+        channel = actor.get_channel()
         if channel:
             return self.get_channel_outbox_response(request, channel)
         return response.Response({}, status=200)
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index 484a078f79fc930744b8b798c2f82333bf16ae8e..2395a8381442f16a2f7c8eaf9459c65d47633cfe 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -107,12 +107,14 @@ class TrackFilter(
 
 class UploadFilter(audio_filters.IncludeChannelsFilterSet):
     library = filters.CharFilter("library__uuid")
+    channel = filters.CharFilter("library__channel__uuid")
     track = filters.UUIDFilter("track__uuid")
     track_artist = filters.UUIDFilter("track__artist__uuid")
     album_artist = filters.UUIDFilter("track__album__artist__uuid")
     library = filters.UUIDFilter("library__uuid")
     playable = filters.BooleanFilter(field_name="_", method="filter_playable")
     scope = common_filters.ActorScopeFilter(actor_field="library__actor", distinct=True)
+    import_status = common_filters.MultipleQueryFilter(coerce=str)
     q = fields.SmartSearchFilter(
         config=search.SearchConfig(
             search_fields={
@@ -143,6 +145,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
             "library",
             "import_reference",
             "scope",
+            "channel",
         ]
         include_channels_field = "track__artist__channel"
 
diff --git a/api/funkwhale_api/music/licenses.py b/api/funkwhale_api/music/licenses.py
index 5690a912f394edcc76ab7fe9091dd91c6ad74112..599d6a7dad3058e9ec31db8a7fdf1f34862c17cf 100644
--- a/api/funkwhale_api/music/licenses.py
+++ b/api/funkwhale_api/music/licenses.py
@@ -30,12 +30,12 @@ def load(data):
         try:
             license = existing_by_code[row["code"]]
         except KeyError:
-            logger.info("Loading new license: {}".format(row["code"]))
+            logger.debug("Loading new license: {}".format(row["code"]))
             to_create.append(
                 models.License(code=row["code"], **{f: row[f] for f in MODEL_FIELDS})
             )
         else:
-            logger.info("Updating license: {}".format(row["code"]))
+            logger.debug("Updating license: {}".format(row["code"]))
             stored = [getattr(license, f) for f in MODEL_FIELDS]
             wanted = [row[f] for f in MODEL_FIELDS]
             if wanted == stored:
diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py
index ee24995e76d60a78dccb4266bf992d9aea365884..4e05580103f15e7282dd04266081f89493775cfb 100644
--- a/api/funkwhale_api/music/metadata.py
+++ b/api/funkwhale_api/music/metadata.py
@@ -512,9 +512,10 @@ class ArtistField(serializers.Field):
                 mbid = None
             artist = {"name": name, "mbid": mbid}
             final.append(artist)
-
-        field = serializers.ListField(child=ArtistSerializer(), min_length=1)
-
+        field = serializers.ListField(
+            child=ArtistSerializer(strict=self.context.get("strict", True)),
+            min_length=1,
+        )
         return field.to_internal_value(final)
 
 
@@ -647,15 +648,29 @@ class MBIDField(serializers.UUIDField):
 
 
 class ArtistSerializer(serializers.Serializer):
-    name = serializers.CharField()
+    name = serializers.CharField(required=False, allow_null=True)
     mbid = MBIDField()
 
+    def __init__(self, *args, **kwargs):
+        self.strict = kwargs.pop("strict", True)
+        super().__init__(*args, **kwargs)
+
+    def validate_name(self, v):
+        if self.strict and not v:
+            raise serializers.ValidationError("This field is required.")
+        return v
+
 
 class AlbumSerializer(serializers.Serializer):
-    title = serializers.CharField()
+    title = serializers.CharField(required=False, allow_null=True)
     mbid = MBIDField()
     release_date = PermissiveDateField(required=False, allow_null=True)
 
+    def validate_title(self, v):
+        if self.context.get("strict", True) and not v:
+            raise serializers.ValidationError("This field is required.")
+        return v
+
 
 class PositionField(serializers.CharField):
     def to_internal_value(self, v):
@@ -691,7 +706,7 @@ class DescriptionField(serializers.CharField):
 
 
 class TrackMetadataSerializer(serializers.Serializer):
-    title = serializers.CharField()
+    title = serializers.CharField(required=False, allow_null=True)
     position = PositionField(allow_blank=True, allow_null=True, required=False)
     disc_number = PositionField(allow_blank=True, allow_null=True, required=False)
     copyright = serializers.CharField(allow_blank=True, allow_null=True, required=False)
@@ -714,6 +729,11 @@ class TrackMetadataSerializer(serializers.Serializer):
         "tags",
     ]
 
+    def validate_title(self, v):
+        if self.context.get("strict", True) and not v:
+            raise serializers.ValidationError("This field is required.")
+        return v
+
     def validate(self, validated_data):
         validated_data = super().validate(validated_data)
         for field in self.remove_blank_null_fields:
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index a5bc37275ccaa9aa1a7587a7dc28c4c03b329581..f27ed3d501ef2c6ef4a0ed8014642aa447011ea0 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -950,7 +950,11 @@ class Upload(models.Model):
 
     def get_all_tagged_items(self):
         track_tags = self.track.tagged_items.all()
-        album_tags = self.track.album.tagged_items.all()
+        album_tags = (
+            self.track.album.tagged_items.all()
+            if self.track.album
+            else tags_models.TaggedItem.objects.none()
+        )
         artist_tags = self.track.artist.tagged_items.all()
 
         items = (track_tags | album_tags | artist_tags).order_by("tag__name")
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 4113c3d7a56f3eee70cc73628bb0341eb45ac6f0..7099231bae9b5949d6d23830b175bf6b506e6304 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -6,16 +6,30 @@ from django.conf import settings
 from rest_framework import serializers
 
 from funkwhale_api.activity import serializers as activity_serializers
-from funkwhale_api.audio import serializers as audio_serializers
+from funkwhale_api.common import models as common_models
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.common import utils as common_utils
 from funkwhale_api.federation import routes
 from funkwhale_api.federation import utils as federation_utils
 from funkwhale_api.playlists import models as playlists_models
-from funkwhale_api.tags.models import Tag
+from funkwhale_api.tags import models as tag_models
 from funkwhale_api.tags import serializers as tags_serializers
 
-from . import filters, models, tasks
+from . import filters, models, tasks, utils
+
+NOOP = object()
+
+COVER_WRITE_FIELD = common_serializers.RelatedField(
+    "uuid",
+    queryset=common_models.Attachment.objects.all().local(),
+    serializer=None,
+    allow_null=True,
+    required=False,
+    queryset_filter=lambda qs, context: qs.filter(actor=context["request"].user.actor),
+    write_only=True,
+)
+
+from funkwhale_api.audio import serializers as audio_serializers  # NOQA
 
 
 class CoverField(
@@ -381,9 +395,30 @@ class UploadSerializer(serializers.ModelSerializer):
             "import_date",
         ]
 
+    def validate(self, data):
+        validated_data = super().validate(data)
+        if "audio_file" in validated_data:
+            audio_data = utils.get_audio_file_data(validated_data["audio_file"])
+            if audio_data:
+                validated_data["duration"] = audio_data["length"]
+                validated_data["bitrate"] = audio_data["bitrate"]
+        return validated_data
+
+
+def filter_album(qs, context):
+    if "channel" in context:
+        return qs.filter(artist__channel=context["channel"])
+    if "actor" in context:
+        return qs.filter(artist__attributed_to=context["actor"])
+
+    return qs.none()
+
 
 class ImportMetadataSerializer(serializers.Serializer):
     title = serializers.CharField(max_length=500, required=True)
+    description = serializers.CharField(
+        max_length=5000, required=False, allow_null=True
+    )
     mbid = serializers.UUIDField(required=False, allow_null=True)
     copyright = serializers.CharField(max_length=500, required=False, allow_null=True)
     position = serializers.IntegerField(min_value=1, required=False, allow_null=True)
@@ -391,12 +426,32 @@ class ImportMetadataSerializer(serializers.Serializer):
     license = common_serializers.RelatedField(
         "code", LicenseSerializer(), required=False, allow_null=True
     )
+    cover = common_serializers.RelatedField(
+        "uuid",
+        queryset=common_models.Attachment.objects.all().local(),
+        serializer=None,
+        queryset_filter=lambda qs, context: qs.filter(actor=context["actor"]),
+        write_only=True,
+        required=False,
+        allow_null=True,
+    )
+    album = common_serializers.RelatedField(
+        "id",
+        queryset=models.Album.objects.all(),
+        serializer=None,
+        queryset_filter=filter_album,
+        write_only=True,
+        required=False,
+        allow_null=True,
+    )
 
 
 class ImportMetadataField(serializers.JSONField):
     def to_internal_value(self, v):
         v = super().to_internal_value(v)
-        s = ImportMetadataSerializer(data=v)
+        s = ImportMetadataSerializer(
+            data=v, context={"actor": self.context["user"].actor}
+        )
         s.is_valid(raise_exception=True)
         return v
 
@@ -464,6 +519,7 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
     actions = [
         common_serializers.Action("delete", allow_all=True),
         common_serializers.Action("relaunch_import", allow_all=True),
+        common_serializers.Action("publish", allow_all=False),
     ]
     filterset_class = filters.UploadFilter
     pk_field = "uuid"
@@ -490,10 +546,18 @@ class UploadActionSerializer(common_serializers.ActionSerializer):
         for pk in pks:
             common_utils.on_commit(tasks.process_upload.delay, upload_id=pk)
 
+    @transaction.atomic
+    def handle_publish(self, objects):
+        qs = objects.filter(import_status="draft")
+        pks = list(qs.values_list("id", flat=True))
+        qs.update(import_status="pending")
+        for pk in pks:
+            common_utils.on_commit(tasks.process_upload.delay, upload_id=pk)
+
 
 class TagSerializer(serializers.ModelSerializer):
     class Meta:
-        model = Tag
+        model = tag_models.Tag
         fields = ("id", "name", "creation_date")
 
 
@@ -509,7 +573,7 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
     type = serializers.SerializerMethodField()
     name = serializers.CharField(source="title")
     artist = serializers.CharField(source="artist.name")
-    album = serializers.CharField(source="album.title")
+    album = serializers.SerializerMethodField()
 
     class Meta:
         model = models.Track
@@ -518,6 +582,10 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
     def get_type(self, obj):
         return "Audio"
 
+    def get_album(self, o):
+        if o.album:
+            return o.album.title
+
 
 def get_embed_url(type, id):
     return settings.FUNKWHALE_EMBED_URL + "?type={}&id={}".format(type, id)
@@ -561,7 +629,13 @@ class OembedSerializer(serializers.Serializer):
             embed_type = "track"
             embed_id = track.pk
             data["title"] = "{} by {}".format(track.title, track.artist.name)
-            if track.album.attachment_cover:
+            if track.attachment_cover:
+                data[
+                    "thumbnail_url"
+                ] = track.album.attachment_cover.download_url_medium_square_crop
+                data["thumbnail_width"] = 200
+                data["thumbnail_height"] = 200
+            elif track.album and track.album.attachment_cover:
                 data[
                     "thumbnail_url"
                 ] = track.album.attachment_cover.download_url_medium_square_crop
@@ -630,7 +704,16 @@ class OembedSerializer(serializers.Serializer):
         elif match.url_name == "channel_detail":
             from funkwhale_api.audio.models import Channel
 
-            qs = Channel.objects.filter(uuid=match.kwargs["uuid"]).select_related(
+            kwargs = {}
+            if "uuid" in match.kwargs:
+                kwargs["uuid"] = match.kwargs["uuid"]
+            else:
+                username_data = federation_utils.get_actor_data_from_username(
+                    match.kwargs["username"]
+                )
+                kwargs["actor__domain"] = username_data["domain"]
+                kwargs["actor__preferred_username__iexact"] = username_data["username"]
+            qs = Channel.objects.filter(**kwargs).select_related(
                 "artist__attachment_cover"
             )
             try:
@@ -705,3 +788,46 @@ class OembedSerializer(serializers.Serializer):
 
     def create(self, data):
         return data
+
+
+class AlbumCreateSerializer(serializers.Serializer):
+    title = serializers.CharField(required=True, max_length=255)
+    cover = COVER_WRITE_FIELD
+    release_date = serializers.DateField(required=False, allow_null=True)
+    tags = tags_serializers.TagsListField(required=False)
+    description = common_serializers.ContentSerializer(allow_null=True, required=False)
+
+    artist = common_serializers.RelatedField(
+        "id",
+        queryset=models.Artist.objects.exclude(channel__isnull=True),
+        required=True,
+        serializer=None,
+        filters=lambda context: {"attributed_to": context["user"].actor},
+    )
+
+    def validate(self, validated_data):
+        duplicates = validated_data["artist"].albums.filter(
+            title__iexact=validated_data["title"]
+        )
+        if duplicates.exists():
+            raise serializers.ValidationError("An album with this title already exist")
+
+        return super().validate(validated_data)
+
+    def to_representation(self, obj):
+        obj.artist.attachment_cover
+        return AlbumSerializer(obj, context=self.context).data
+
+    def create(self, validated_data):
+        instance = models.Album.objects.create(
+            attributed_to=self.context["user"].actor,
+            artist=validated_data["artist"],
+            release_date=validated_data.get("release_date"),
+            title=validated_data["title"],
+            attachment_cover=validated_data.get("cover"),
+        )
+        common_utils.attach_content(
+            instance, "description", validated_data.get("description")
+        )
+        tag_models.set_tags(instance, *(validated_data.get("tags", []) or []))
+        return instance
diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py
index e96ce5fee345522adaa9f7c52aa4540c9234d48c..0619166a664d7d028cce756c959f1d5fb48cfa54 100644
--- a/api/funkwhale_api/music/spa_views.py
+++ b/api/funkwhale_api/music/spa_views.py
@@ -49,16 +49,29 @@ def library_track(request, pk):
                 utils.spa_reverse("library_artist", kwargs={"pk": obj.artist.pk}),
             ),
         },
-        {
-            "tag": "meta",
-            "property": "music:album",
-            "content": utils.join_url(
-                settings.FUNKWHALE_URL,
-                utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
-            ),
-        },
     ]
-    if obj.album.attachment_cover:
+
+    if obj.album:
+        metas.append(
+            {
+                "tag": "meta",
+                "property": "music:album",
+                "content": utils.join_url(
+                    settings.FUNKWHALE_URL,
+                    utils.spa_reverse("library_album", kwargs={"pk": obj.album.pk}),
+                ),
+            },
+        )
+
+    if obj.attachment_cover:
+        metas.append(
+            {
+                "tag": "meta",
+                "property": "og:image",
+                "content": obj.attachment_cover.download_url_medium_square_crop,
+            }
+        )
+    elif obj.album and obj.album.attachment_cover:
         metas.append(
             {
                 "tag": "meta",
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index dce014f15df479a35be8d19d82f9cfa853e176ae..1d53c75fba4248d3b6a39f403e2a419510b2368b 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -175,46 +175,69 @@ def fail_import(upload, error_code, detail=None, **fields):
     "upload",
 )
 def process_upload(upload, update_denormalization=True):
+    """
+    Main handler to process uploads submitted by user and create the corresponding
+    metadata (tracks/artists/albums) in our DB.
+    """
     from . import serializers
 
+    channel = upload.library.get_channel()
+    # When upload is linked to a channel instead of a library
+    # we willingly ignore the metadata embedded in the file itself
+    # and rely on user metadata only
+    use_file_metadata = channel is None
+
     import_metadata = upload.import_metadata or {}
     internal_config = {"funkwhale": import_metadata.get("funkwhale", {})}
     forced_values_serializer = serializers.ImportMetadataSerializer(
-        data=import_metadata
+        data=import_metadata,
+        context={"actor": upload.library.actor, "channel": channel},
     )
     if forced_values_serializer.is_valid():
         forced_values = forced_values_serializer.validated_data
     else:
         forced_values = {}
+        if not use_file_metadata:
+            detail = forced_values_serializer.errors
+            metadata_dump = import_metadata
+            return fail_import(
+                upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
+            )
 
-    if upload.library.get_channel():
+    if channel:
         # ensure the upload is associated with the channel artist
         forced_values["artist"] = upload.library.channel.artist
+
     old_status = upload.import_status
-    audio_file = upload.get_audio_file()
-    additional_data = {}
+    additional_data = {"upload_source": upload.source}
 
-    m = metadata.Metadata(audio_file)
-    try:
-        serializer = metadata.TrackMetadataSerializer(data=m)
-        serializer.is_valid()
-    except Exception:
-        fail_import(upload, "unknown_error")
-        raise
-    if not serializer.is_valid():
-        detail = serializer.errors
+    if use_file_metadata:
+        audio_file = upload.get_audio_file()
+
+        m = metadata.Metadata(audio_file)
         try:
-            metadata_dump = m.all()
-        except Exception as e:
-            logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
-        return fail_import(
-            upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
-        )
+            serializer = metadata.TrackMetadataSerializer(data=m)
+            serializer.is_valid()
+        except Exception:
+            fail_import(upload, "unknown_error")
+            raise
+        if not serializer.is_valid():
+            detail = serializer.errors
+            try:
+                metadata_dump = m.all()
+            except Exception as e:
+                logger.warn("Cannot dump metadata for file %s: %s", audio_file, str(e))
+            return fail_import(
+                upload, "invalid_metadata", detail=detail, file_metadata=metadata_dump
+            )
 
-    final_metadata = collections.ChainMap(
-        additional_data, serializer.validated_data, internal_config
-    )
-    additional_data["upload_source"] = upload.source
+        final_metadata = collections.ChainMap(
+            additional_data, serializer.validated_data, internal_config
+        )
+    else:
+        final_metadata = collections.ChainMap(
+            additional_data, forced_values, internal_config,
+        )
     try:
         track = get_track_from_import_metadata(
             final_metadata, attributed_to=upload.library.actor, **forced_values
@@ -275,7 +298,7 @@ def process_upload(upload, update_denormalization=True):
         )
 
     # update album cover, if needed
-    if not track.album.attachment_cover:
+    if track.album and not track.album.attachment_cover:
         populate_album_cover(
             track.album, source=final_metadata.get("upload_source"),
         )
@@ -466,7 +489,11 @@ def _get_track(data, attributed_to=None, **forced_values):
     track_mbid = (
         forced_values["mbid"] if "mbid" in forced_values else data.get("mbid", None)
     )
-    album_mbid = getter(data, "album", "mbid")
+    try:
+        album_mbid = getter(data, "album", "mbid")
+    except TypeError:
+        # album is forced
+        album_mbid = None
     track_fid = getter(data, "fid")
 
     query = None
@@ -528,81 +555,94 @@ def _get_track(data, attributed_to=None, **forced_values):
     if "album" in forced_values:
         album = forced_values["album"]
     else:
-        album_artists = getter(data, "album", "artists", default=artists) or artists
-        album_artist_data = album_artists[0]
-        album_artist_name = truncate(
-            album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
-        )
-        if album_artist_name == artist_name:
-            album_artist = artist
+        if "artist" in forced_values:
+            album_artist = forced_values["artist"]
         else:
-            query = Q(name__iexact=album_artist_name)
-            album_artist_mbid = album_artist_data.get("mbid", None)
-            album_artist_fid = album_artist_data.get("fid", None)
-            if album_artist_mbid:
-                query |= Q(mbid=album_artist_mbid)
-            if album_artist_fid:
-                query |= Q(fid=album_artist_fid)
+            album_artists = getter(data, "album", "artists", default=artists) or artists
+            album_artist_data = album_artists[0]
+            album_artist_name = truncate(
+                album_artist_data.get("name"), models.MAX_LENGTHS["ARTIST_NAME"]
+            )
+            if album_artist_name == artist_name:
+                album_artist = artist
+            else:
+                query = Q(name__iexact=album_artist_name)
+                album_artist_mbid = album_artist_data.get("mbid", None)
+                album_artist_fid = album_artist_data.get("fid", None)
+                if album_artist_mbid:
+                    query |= Q(mbid=album_artist_mbid)
+                if album_artist_fid:
+                    query |= Q(fid=album_artist_fid)
+                defaults = {
+                    "name": album_artist_name,
+                    "mbid": album_artist_mbid,
+                    "fid": album_artist_fid,
+                    "from_activity_id": from_activity_id,
+                    "attributed_to": album_artist_data.get(
+                        "attributed_to", attributed_to
+                    ),
+                }
+                if album_artist_data.get("fdate"):
+                    defaults["creation_date"] = album_artist_data.get("fdate")
+
+                album_artist, created = get_best_candidate_or_create(
+                    models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
+                )
+                if created:
+                    tags_models.add_tags(
+                        album_artist, *album_artist_data.get("tags", [])
+                    )
+                    common_utils.attach_content(
+                        album_artist,
+                        "description",
+                        album_artist_data.get("description"),
+                    )
+                    common_utils.attach_file(
+                        album_artist,
+                        "attachment_cover",
+                        album_artist_data.get("cover_data"),
+                    )
+
+        # get / create album
+        if "album" in data:
+            album_data = data["album"]
+            album_title = truncate(
+                album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"]
+            )
+            album_fid = album_data.get("fid", None)
+
+            if album_mbid:
+                query = Q(mbid=album_mbid)
+            else:
+                query = Q(title__iexact=album_title, artist=album_artist)
+
+            if album_fid:
+                query |= Q(fid=album_fid)
             defaults = {
-                "name": album_artist_name,
-                "mbid": album_artist_mbid,
-                "fid": album_artist_fid,
+                "title": album_title,
+                "artist": album_artist,
+                "mbid": album_mbid,
+                "release_date": album_data.get("release_date"),
+                "fid": album_fid,
                 "from_activity_id": from_activity_id,
-                "attributed_to": album_artist_data.get("attributed_to", attributed_to),
+                "attributed_to": album_data.get("attributed_to", attributed_to),
             }
-            if album_artist_data.get("fdate"):
-                defaults["creation_date"] = album_artist_data.get("fdate")
+            if album_data.get("fdate"):
+                defaults["creation_date"] = album_data.get("fdate")
 
-            album_artist, created = get_best_candidate_or_create(
-                models.Artist, query, defaults=defaults, sort_fields=["mbid", "fid"]
+            album, created = get_best_candidate_or_create(
+                models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
             )
             if created:
-                tags_models.add_tags(album_artist, *album_artist_data.get("tags", []))
+                tags_models.add_tags(album, *album_data.get("tags", []))
                 common_utils.attach_content(
-                    album_artist, "description", album_artist_data.get("description")
+                    album, "description", album_data.get("description")
                 )
                 common_utils.attach_file(
-                    album_artist,
-                    "attachment_cover",
-                    album_artist_data.get("cover_data"),
+                    album, "attachment_cover", album_data.get("cover_data")
                 )
-
-        # get / create album
-        album_data = data["album"]
-        album_title = truncate(album_data["title"], models.MAX_LENGTHS["ALBUM_TITLE"])
-        album_fid = album_data.get("fid", None)
-
-        if album_mbid:
-            query = Q(mbid=album_mbid)
         else:
-            query = Q(title__iexact=album_title, artist=album_artist)
-
-        if album_fid:
-            query |= Q(fid=album_fid)
-        defaults = {
-            "title": album_title,
-            "artist": album_artist,
-            "mbid": album_mbid,
-            "release_date": album_data.get("release_date"),
-            "fid": album_fid,
-            "from_activity_id": from_activity_id,
-            "attributed_to": album_data.get("attributed_to", attributed_to),
-        }
-        if album_data.get("fdate"):
-            defaults["creation_date"] = album_data.get("fdate")
-
-        album, created = get_best_candidate_or_create(
-            models.Album, query, defaults=defaults, sort_fields=["mbid", "fid"]
-        )
-        if created:
-            tags_models.add_tags(album, *album_data.get("tags", []))
-            common_utils.attach_content(
-                album, "description", album_data.get("description")
-            )
-            common_utils.attach_file(
-                album, "attachment_cover", album_data.get("cover_data")
-            )
-
+            album = None
     # get / create track
     track_title = (
         forced_values["title"]
@@ -629,6 +669,14 @@ def _get_track(data, attributed_to=None, **forced_values):
         if "copyright" in forced_values
         else truncate(data.get("copyright"), models.MAX_LENGTHS["COPYRIGHT"])
     )
+    description = (
+        {"text": forced_values["description"], "content_type": "text/markdown"}
+        if "description" in forced_values
+        else data.get("description")
+    )
+    cover_data = (
+        forced_values["cover"] if "cover" in forced_values else data.get("cover_data")
+    )
 
     query = Q(
         title__iexact=track_title,
@@ -670,8 +718,8 @@ def _get_track(data, attributed_to=None, **forced_values):
             forced_values["tags"] if "tags" in forced_values else data.get("tags", [])
         )
         tags_models.add_tags(track, *tags)
-        common_utils.attach_content(track, "description", data.get("description"))
-        common_utils.attach_file(track, "attachment_cover", data.get("cover_data"))
+        common_utils.attach_content(track, "description", description)
+        common_utils.attach_file(track, "attachment_cover", cover_data)
 
     return track
 
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 8ca2164f6c690a96fe6f49fa797a312f604284a6..47aa60b89ed9a9fa5905fe88eb1c25930c7ce056 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -173,6 +173,8 @@ class ArtistViewSet(
 class AlbumViewSet(
     HandleInvalidSearch,
     common_views.SkipFilterForGetObject,
+    mixins.CreateModelMixin,
+    mixins.DestroyModelMixin,
     viewsets.ReadOnlyModelViewSet,
 ):
     queryset = (
@@ -202,11 +204,19 @@ class AlbumViewSet(
 
     def get_serializer_context(self):
         context = super().get_serializer_context()
-        context["description"] = self.action in ["retrieve", "create", "update"]
+        context["description"] = self.action in [
+            "retrieve",
+            "create",
+        ]
+        context["user"] = self.request.user
         return context
 
     def get_queryset(self):
         queryset = super().get_queryset()
+        if self.action in ["destroy"]:
+            queryset = queryset.exclude(artist__channel=None).filter(
+                artist__attributed_to=self.request.user.actor
+            )
         tracks = (
             models.Track.objects.prefetch_related("artist")
             .with_playable_uploads(utils.get_actor_from_request(self.request))
@@ -221,6 +231,11 @@ class AlbumViewSet(
         get_libraries(filter_uploads=lambda o, uploads: uploads.filter(track__album=o))
     )
 
+    def get_serializer_class(self):
+        if self.action in ["create"]:
+            return serializers.AlbumCreateSerializer
+        return super().get_serializer_class()
+
 
 class LibraryViewSet(
     mixins.CreateModelMixin,
@@ -288,6 +303,7 @@ class LibraryViewSet(
 class TrackViewSet(
     HandleInvalidSearch,
     common_views.SkipFilterForGetObject,
+    mixins.DestroyModelMixin,
     viewsets.ReadOnlyModelViewSet,
 ):
     """
@@ -330,6 +346,10 @@ class TrackViewSet(
 
     def get_queryset(self):
         queryset = super().get_queryset()
+        if self.action in ["destroy"]:
+            queryset = queryset.exclude(artist__channel=None).filter(
+                artist__attributed_to=self.request.user.actor
+            )
         filter_favorites = self.request.GET.get("favorites", None)
         user = self.request.user
         if user.is_authenticated and filter_favorites == "true":
@@ -617,18 +637,17 @@ class UploadViewSet(
             m = tasks.metadata.Metadata(upload.get_audio_file())
         except FileNotFoundError:
             return Response({"detail": "File not found"}, status=500)
-        serializer = tasks.metadata.TrackMetadataSerializer(data=m)
+        serializer = tasks.metadata.TrackMetadataSerializer(
+            data=m, context={"strict": False}
+        )
         if not serializer.is_valid():
             return Response(serializer.errors, status=500)
         payload = serializer.validated_data
-        if (
-            "cover_data" in payload
-            and payload["cover_data"]
-            and "content" in payload["cover_data"]
-        ):
-            payload["cover_data"]["content"] = base64.b64encode(
-                payload["cover_data"]["content"]
-            )
+        cover_data = payload.get(
+            "cover_data", payload.get("album", {}).get("cover_data", {})
+        )
+        if cover_data and "content" in cover_data:
+            cover_data["content"] = base64.b64encode(cover_data["content"])
         return Response(payload, status=200)
 
     @action(methods=["post"], detail=False)
diff --git a/api/funkwhale_api/static/images/podcasts-cover-placeholder.png b/api/funkwhale_api/static/images/podcasts-cover-placeholder.png
new file mode 100644
index 0000000000000000000000000000000000000000..1f6809a1d0e8e60a41a0a578e4a60d49d401f86f
Binary files /dev/null and b/api/funkwhale_api/static/images/podcasts-cover-placeholder.png differ
diff --git a/api/tests/audio/test_models.py b/api/tests/audio/test_models.py
index 8a0d6e4b82b4a555669b1643ef0f5592ec2e9d0a..6c513684421f06b816196a95cb4f08f93c32e731 100644
--- a/api/tests/audio/test_models.py
+++ b/api/tests/audio/test_models.py
@@ -1,3 +1,5 @@
+import pytest
+
 from django.urls import reverse
 
 from funkwhale_api.federation import utils as federation_utils
@@ -15,7 +17,10 @@ def test_channel(factories, now):
 def test_channel_get_rss_url_local(factories):
     channel = factories["audio.Channel"](artist__local=True)
     expected = federation_utils.full_url(
-        reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
+        reverse(
+            "api:v1:channels-rss",
+            kwargs={"composite": channel.actor.preferred_username},
+        )
     )
     assert channel.get_rss_url() == expected
 
@@ -23,3 +28,15 @@ def test_channel_get_rss_url_local(factories):
 def test_channel_get_rss_url_remote(factories):
     channel = factories["audio.Channel"]()
     assert channel.get_rss_url() == channel.rss_url
+
+
+def test_channel_delete(factories):
+    channel = factories["audio.Channel"]()
+    library = channel.library
+    actor = channel.library
+    artist = channel.artist
+    channel.delete()
+
+    for obj in [library, actor, artist]:
+        with pytest.raises(obj.DoesNotExist):
+            obj.refresh_from_db()
diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py
index 0d664969d91b85a992fcfc12944c6e034660cc77..901de126be5b261b3ea6d13f5cfbf65d4d4259bf 100644
--- a/api/tests/audio/test_serializers.py
+++ b/api/tests/audio/test_serializers.py
@@ -3,6 +3,8 @@ import datetime
 import pytest
 import pytz
 
+from django.contrib.staticfiles.templatetags.staticfiles import static
+
 from funkwhale_api.audio import serializers
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.common import utils as common_utils
@@ -11,20 +13,21 @@ from funkwhale_api.federation import utils as federation_utils
 from funkwhale_api.music import serializers as music_serializers
 
 
-def test_channel_serializer_create(factories):
+def test_channel_serializer_create(factories, mocker):
     attributed_to = factories["federation.Actor"](local=True)
-
+    attachment = factories["common.Attachment"](actor=attributed_to)
+    request = mocker.Mock(user=mocker.Mock(actor=attributed_to))
     data = {
-        # TODO: cover
         "name": "My channel",
         "username": "mychannel",
         "description": {"text": "This is my channel", "content_type": "text/markdown"},
         "tags": ["hello", "world"],
         "content_category": "other",
+        "cover": attachment.uuid,
     }
 
     serializer = serializers.ChannelCreateSerializer(
-        data=data, context={"actor": attributed_to}
+        data=data, context={"actor": attributed_to, "request": request}
     )
     assert serializer.is_valid(raise_exception=True) is True
 
@@ -37,14 +40,12 @@ def test_channel_serializer_create(factories):
         == data["tags"]
     )
     assert channel.artist.description.text == data["description"]["text"]
+    assert channel.artist.attachment_cover == attachment
     assert channel.artist.content_category == data["content_category"]
     assert (
         channel.artist.description.content_type == data["description"]["content_type"]
     )
     assert channel.attributed_to == attributed_to
-    assert channel.actor.summary == common_utils.render_html(
-        data["description"]["text"], "text/markdown"
-    )
     assert channel.actor.preferred_username == data["username"]
     assert channel.actor.name == data["name"]
     assert channel.library.privacy_level == "everyone"
@@ -150,24 +151,31 @@ def test_channel_serializer_create_podcast(factories):
     assert channel.metadata == data["metadata"]
 
 
-def test_channel_serializer_update(factories):
-    channel = factories["audio.Channel"](artist__set_tags=["rock"])
-
+def test_channel_serializer_update(factories, mocker):
+    channel = factories["audio.Channel"](
+        artist__set_tags=["rock"], attributed_to__local=True
+    )
+    attributed_to = channel.attributed_to
+    attachment = factories["common.Attachment"](actor=attributed_to)
+    request = mocker.Mock(user=mocker.Mock(actor=attributed_to))
     data = {
-        # TODO: cover
         "name": "My channel",
         "description": {"text": "This is my channel", "content_type": "text/markdown"},
         "tags": ["hello", "world"],
         "content_category": "other",
+        "cover": attachment.uuid,
     }
 
-    serializer = serializers.ChannelUpdateSerializer(channel, data=data)
+    serializer = serializers.ChannelUpdateSerializer(
+        channel, data=data, context={"request": request}
+    )
     assert serializer.is_valid(raise_exception=True) is True
 
     serializer.save()
     channel.refresh_from_db()
 
     assert channel.artist.name == data["name"]
+    assert channel.artist.attachment_cover == attachment
     assert channel.artist.content_category == data["content_category"]
     assert (
         sorted(channel.artist.tagged_items.values_list("tag__name", flat=True))
@@ -281,7 +289,7 @@ def test_rss_item_serializer(factories):
             {
                 "url": federation_utils.full_url(upload.get_listen_url("mp3")),
                 "length": upload.size,
-                "type": upload.mimetype,
+                "type": "audio/mpeg",
             }
         ],
     }
@@ -350,6 +358,30 @@ def test_rss_channel_serializer(factories):
     assert serializers.rss_serialize_channel(channel) == expected
 
 
+def test_rss_channel_serializer_placeholder_image(factories):
+    description = factories["common.Content"]()
+    channel = factories["audio.Channel"](
+        artist__set_tags=["pop", "rock"],
+        artist__description=description,
+        artist__attachment_cover=None,
+    )
+    setattr(
+        channel.artist,
+        "_prefetched_tagged_items",
+        channel.artist.tagged_items.order_by("tag__name"),
+    )
+
+    expected = [
+        {
+            "href": federation_utils.full_url(
+                static("images/podcasts-cover-placeholder.png")
+            )
+        }
+    ]
+
+    assert serializers.rss_serialize_channel(channel)["itunes:image"] == expected
+
+
 def test_serialize_full_channel(factories):
     channel = factories["audio.Channel"]()
     upload1 = factories["music.Upload"](playable=True)
diff --git a/api/tests/audio/test_spa_views.py b/api/tests/audio/test_spa_views.py
index 9bb96807acc67dfcb1faf5d41e73ec2e88f09350..265aea0a38cc3e3d0f43137678220026aa894fcb 100644
--- a/api/tests/audio/test_spa_views.py
+++ b/api/tests/audio/test_spa_views.py
@@ -1,3 +1,5 @@
+import pytest
+
 import urllib.parse
 
 from django.urls import reverse
@@ -7,18 +9,21 @@ from funkwhale_api.federation import utils as federation_utils
 from funkwhale_api.music import serializers
 
 
-def test_library_artist(spa_html, no_api_auth, client, factories, settings):
+@pytest.mark.parametrize("attribute", ["uuid", "actor.full_username"])
+def test_channel_detail(attribute, spa_html, no_api_auth, client, factories, settings):
     channel = factories["audio.Channel"]()
     factories["music.Upload"](playable=True, library=channel.library)
-    url = "/channels/{}".format(channel.uuid)
+    url = "/channels/{}".format(utils.recursive_getattr(channel, attribute))
+    detail_url = "/channels/{}".format(channel.actor.full_username)
 
     response = client.get(url)
 
+    assert response.status_code == 200
     expected_metas = [
         {
             "tag": "meta",
             "property": "og:url",
-            "content": utils.join_url(settings.FUNKWHALE_URL, url),
+            "content": utils.join_url(settings.FUNKWHALE_URL, detail_url),
         },
         {"tag": "meta", "property": "og:title", "content": channel.artist.name},
         {"tag": "meta", "property": "og:type", "content": "profile"},
@@ -47,7 +52,9 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings):
             "href": (
                 utils.join_url(settings.FUNKWHALE_URL, reverse("api:v1:oembed"))
                 + "?format=json&url={}".format(
-                    urllib.parse.quote_plus(utils.join_url(settings.FUNKWHALE_URL, url))
+                    urllib.parse.quote_plus(
+                        utils.join_url(settings.FUNKWHALE_URL, detail_url)
+                    )
                 )
             ),
         },
diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py
index 335058f8579f67da5089f8efcb1acbcf91eef576..4a762c6f1bf19b64a9fe5d695009bf1f9a8c859f 100644
--- a/api/tests/audio/test_views.py
+++ b/api/tests/audio/test_views.py
@@ -3,8 +3,11 @@ import pytest
 
 from django.urls import reverse
 
+from funkwhale_api.audio import categories
 from funkwhale_api.audio import serializers
 from funkwhale_api.audio import views
+from funkwhale_api.common import locales
+from funkwhale_api.common import utils
 
 
 def test_channel_create(logged_in_api_client):
@@ -38,15 +41,25 @@ def test_channel_create(logged_in_api_client):
         == data["tags"]
     )
     assert channel.attributed_to == actor
-    assert channel.actor.summary == channel.artist.description.rendered
+    assert channel.artist.description.text == data["description"]["text"]
+    assert (
+        channel.artist.description.content_type == data["description"]["content_type"]
+    )
     assert channel.actor.preferred_username == data["username"]
     assert channel.library.privacy_level == "everyone"
     assert channel.library.actor == actor
 
 
-def test_channel_detail(factories, logged_in_api_client):
-    channel = factories["audio.Channel"](artist__description=None)
-    url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
+@pytest.mark.parametrize(
+    "field", ["uuid", "actor.preferred_username", "actor.full_username"],
+)
+def test_channel_detail(field, factories, logged_in_api_client):
+    channel = factories["audio.Channel"](artist__description=None, local=True)
+
+    url = reverse(
+        "api:v1:channels-detail",
+        kwargs={"composite": utils.recursive_getattr(channel, field)},
+    )
     setattr(channel.artist, "_tracks_count", 0)
     setattr(channel.artist, "_prefetched_tagged_items", [])
 
@@ -85,7 +98,7 @@ def test_channel_update(logged_in_api_client, factories):
         "name": "new name"
     }
 
-    url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
     response = logged_in_api_client.patch(url, data)
 
     assert response.status_code == 200
@@ -101,7 +114,7 @@ def test_channel_update_permission(logged_in_api_client, factories):
 
     data = {"name": "new name"}
 
-    url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
     response = logged_in_api_client.patch(url, data)
 
     assert response.status_code == 403
@@ -112,7 +125,7 @@ def test_channel_delete(logged_in_api_client, factories, mocker):
     actor = logged_in_api_client.user.create_actor()
     channel = factories["audio.Channel"](attributed_to=actor)
 
-    url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
     dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
     response = logged_in_api_client.delete(url)
 
@@ -131,7 +144,7 @@ def test_channel_delete_permission(logged_in_api_client, factories):
     logged_in_api_client.user.create_actor()
     channel = factories["audio.Channel"]()
 
-    url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
     response = logged_in_api_client.patch(url)
 
     assert response.status_code == 403
@@ -151,7 +164,7 @@ def test_channel_views_disabled_via_feature_flag(
 def test_channel_subscribe(factories, logged_in_api_client):
     actor = logged_in_api_client.user.create_actor()
     channel = factories["audio.Channel"](artist__description=None)
-    url = reverse("api:v1:channels-subscribe", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-subscribe", kwargs={"composite": channel.uuid})
 
     response = logged_in_api_client.post(url)
 
@@ -173,7 +186,7 @@ def test_channel_unsubscribe(factories, logged_in_api_client):
     actor = logged_in_api_client.user.create_actor()
     channel = factories["audio.Channel"]()
     subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
-    url = reverse("api:v1:channels-unsubscribe", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-unsubscribe", kwargs={"composite": channel.uuid})
 
     response = logged_in_api_client.post(url)
 
@@ -229,7 +242,7 @@ def test_channel_rss_feed(factories, api_client, preferences):
         channel=channel, uploads=[upload2, upload1]
     )
 
-    url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
 
     response = api_client.get(url)
 
@@ -242,7 +255,7 @@ def test_channel_rss_feed_remote(factories, api_client, preferences):
     preferences["common__api_authentication_required"] = False
     channel = factories["audio.Channel"]()
 
-    url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
 
     response = api_client.get(url)
 
@@ -253,8 +266,28 @@ def test_channel_rss_feed_authentication_required(factories, api_client, prefere
     preferences["common__api_authentication_required"] = True
     channel = factories["audio.Channel"](local=True)
 
-    url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
+    url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
 
     response = api_client.get(url)
 
     assert response.status_code == 401
+
+
+def test_channel_metadata_choices(factories, api_client):
+
+    expected = {
+        "language": [
+            {"value": code, "label": name} for code, name in locales.ISO_639_CHOICES
+        ],
+        "itunes_category": [
+            {"value": code, "label": code, "children": children}
+            for code, children in categories.ITUNES_CATEGORIES.items()
+        ],
+    }
+
+    url = reverse("api:v1:channels-metadata_choices")
+
+    response = api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == expected
diff --git a/api/tests/common/test_utils.py b/api/tests/common/test_utils.py
index f5b3836aba5eb19b15d743ebd3e34f275cd2002e..5908c60bea127cbd9518c20015769a97c015bd2a 100644
--- a/api/tests/common/test_utils.py
+++ b/api/tests/common/test_utils.py
@@ -174,6 +174,17 @@ def test_attach_file_url_fetch(factories, r_mock):
     assert new_attachment.mimetype == data["mimetype"]
 
 
+def test_attach_file_attachment(factories, r_mock):
+    album = factories["music.Album"]()
+
+    data = factories["common.Attachment"]()
+    utils.attach_file(album, "attachment_cover", data)
+
+    album.refresh_from_db()
+
+    assert album.attachment_cover == data
+
+
 def test_attach_file_content(factories, r_mock):
     album = factories["music.Album"]()
 
diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py
index 0a07acc5887b665d2aee817914d8c356d1f3b352..c96251ecb3d9dcfe19bc080f2496f46e8835b412 100644
--- a/api/tests/federation/test_models.py
+++ b/api/tests/federation/test_models.py
@@ -78,12 +78,21 @@ def test_actor_get_quota(factories):
         audio_file__data=b"aaaa",
     )
 
+    # this one is in a channel
+    channel = factories["audio.Channel"](attributed_to=library.actor)
+    factories["music.Upload"](
+        library=channel.library,
+        import_status="finished",
+        audio_file__from_path=None,
+        audio_file__data=b"aaaaa",
+    )
+
     expected = {
-        "total": 19,
+        "total": 24,
         "pending": 1,
         "skipped": 2,
         "errored": 3,
-        "finished": 8,
+        "finished": 13,
         "draft": 5,
     }
 
diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py
index 83f10e5cd402c1d55076a4652a817c96b0705cce..587ccc33323d9135ed9d5918c89d4dd2ccffb79e 100644
--- a/api/tests/federation/test_routes.py
+++ b/api/tests/federation/test_routes.py
@@ -117,6 +117,41 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
     )
 
 
+def test_inbox_follow_channel_autoapprove(factories, mocker):
+    mocked_outbox_dispatch = mocker.patch(
+        "funkwhale_api.federation.activity.OutboxRouter.dispatch"
+    )
+
+    local_actor = factories["users.User"]().create_actor()
+    remote_actor = factories["federation.Actor"]()
+    channel = factories["audio.Channel"](attributed_to=local_actor)
+    ii = factories["federation.InboxItem"](actor=channel.actor)
+
+    payload = {
+        "type": "Follow",
+        "id": "https://test.follow",
+        "actor": remote_actor.fid,
+        "object": channel.actor.fid,
+    }
+
+    result = routes.inbox_follow(
+        payload,
+        context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
+    )
+    follow = channel.actor.received_follows.latest("id")
+
+    assert result["object"] == channel.actor
+    assert result["related_object"] == follow
+
+    assert follow.fid == payload["id"]
+    assert follow.actor == remote_actor
+    assert follow.approved is True
+
+    mocked_outbox_dispatch.assert_called_once_with(
+        {"type": "Accept"}, context={"follow": follow}
+    )
+
+
 def test_inbox_follow_library_manual_approve(factories, mocker):
     mocked_outbox_dispatch = mocker.patch(
         "funkwhale_api.federation.activity.OutboxRouter.dispatch"
diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py
index 485502ac739975715eb458237afcca219baaa5b2..2488ebcc8dd059ead282ec6137fe1413fc936c79 100644
--- a/api/tests/music/test_metadata.py
+++ b/api/tests/music/test_metadata.py
@@ -657,6 +657,34 @@ def test_serializer_empty_fields(field_name):
     assert serializer.validated_data == expected
 
 
+def test_serializer_strict_mode_false():
+    data = {}
+    expected = {
+        "artists": [{"name": None, "mbid": None}],
+        "album": {
+            "title": "[Unknown Album]",
+            "mbid": None,
+            "release_date": None,
+            "artists": [],
+            "cover_data": None,
+        },
+    }
+    serializer = metadata.TrackMetadataSerializer(
+        data=metadata.FakeMetadata(data), context={"strict": False}
+    )
+    assert serializer.is_valid(raise_exception=True) is True
+    assert serializer.validated_data == expected
+
+
+def test_serializer_strict_mode_true():
+    data = {}
+    serializer = metadata.TrackMetadataSerializer(
+        data=metadata.FakeMetadata(data), context={"strict": True}
+    )
+    with pytest.raises(metadata.serializers.ValidationError):
+        assert serializer.is_valid(raise_exception=True)
+
+
 def test_artist_field_featuring():
     data = {
         "artist": "Santana feat. Chris Cornell",
diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py
index f70313f3f98b19f1f917d4437e6fef9f3991812e..3cffcb814412d1dbc1bbd65d391b13f9c0787a7e 100644
--- a/api/tests/music/test_serializers.py
+++ b/api/tests/music/test_serializers.py
@@ -361,6 +361,19 @@ def test_manage_upload_action_relaunch_import(factories, mocker):
     assert m.call_count == 3
 
 
+def test_manage_upload_action_publish(factories, mocker):
+    m = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    draft = factories["music.Upload"](import_status="draft")
+    s = serializers.UploadActionSerializer(queryset=None)
+
+    s.handle_publish(models.Upload.objects.all())
+
+    draft.refresh_from_db()
+    assert draft.import_status == "pending"
+    m.assert_any_call(tasks.process_upload.delay, upload_id=draft.pk)
+
+
 def test_serialize_upload(factories):
     upload = factories["music.Upload"]()
 
@@ -511,6 +524,18 @@ def test_upload_import_metadata_serializer_full():
     assert serializer.validated_data == expected
 
 
+def test_upload_import_metadata_serializer_channel_checks_owned_album(factories):
+    channel = factories["audio.Channel"]()
+    album = factories["music.Album"]()
+    data = {"title": "hello", "album": album.pk}
+    serializer = serializers.ImportMetadataSerializer(
+        data=data, context={"channel": channel}
+    )
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        serializer.is_valid(raise_exception=True)
+
+
 def test_upload_with_channel_keeps_import_metadata(factories, uploaded_audio_file):
     channel = factories["audio.Channel"](attributed_to__local=True)
     user = channel.attributed_to.user
diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py
index f140143ec7c4acb74fa1b57123a4ae1a68be4840..d5deb8ce685a1782c5934b49436daf628dc1e4a2 100644
--- a/api/tests/music/test_spa_views.py
+++ b/api/tests/music/test_spa_views.py
@@ -7,7 +7,9 @@ from funkwhale_api.music import serializers
 
 
 def test_library_track(spa_html, no_api_auth, client, factories, settings):
-    upload = factories["music.Upload"](playable=True, track__disc_number=1)
+    upload = factories["music.Upload"](
+        playable=True, track__disc_number=1, track__attachment_cover=None
+    )
     track = upload.track
     url = "/library/tracks/{}".format(track.pk)
 
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index bbbc9c7458d96022bdd153c10d8cc57997ff0404..35ed276944dac7a922bfa53e9f12677440217244 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -1014,45 +1014,73 @@ def test_get_track_from_import_metadata_with_forced_values(factories, mocker, fa
     )
 
 
-def test_process_channel_upload_forces_artist_and_attributed_to(
+def test_get_track_from_import_metadata_with_forced_values_album(
     factories, mocker, faker
 ):
-    track = factories["music.Track"]()
     channel = factories["audio.Channel"]()
+    album = factories["music.Album"](artist=channel.artist)
+
+    forced_values = {
+        "title": "Real title",
+        "album": album.pk,
+    }
+    upload = factories["music.Upload"](
+        import_metadata=forced_values, library=channel.library, track=None
+    )
+    tasks.process_upload(upload_id=upload.pk)
+    upload.refresh_from_db()
+    assert upload.import_status == "finished"
+
+    assert upload.track.title == forced_values["title"]
+    assert upload.track.album == album
+    assert upload.track.artist == channel.artist
+
+
+def test_process_channel_upload_forces_artist_and_attributed_to(
+    factories, mocker, faker
+):
+    channel = factories["audio.Channel"](attributed_to__local=True)
+    attachment = factories["common.Attachment"](actor=channel.attributed_to)
     import_metadata = {
         "title": "Real title",
         "position": 3,
         "copyright": "Real copyright",
         "tags": ["hello", "world"],
+        "description": "my description",
+        "cover": attachment.uuid,
     }
-
     expected_forced_values = import_metadata.copy()
     expected_forced_values["artist"] = channel.artist
-    expected_forced_values["attributed_to"] = channel.attributed_to
+    expected_forced_values["cover"] = attachment
     upload = factories["music.Upload"](
         track=None, import_metadata=import_metadata, library=channel.library
     )
-    get_track_from_import_metadata = mocker.patch.object(
-        tasks, "get_track_from_import_metadata", return_value=track
-    )
+    get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
 
     tasks.process_upload(upload_id=upload.pk)
 
     upload.refresh_from_db()
-    serializer = tasks.metadata.TrackMetadataSerializer(
-        data=tasks.metadata.Metadata(upload.get_audio_file())
-    )
-    assert serializer.is_valid() is True
-    audio_metadata = serializer.validated_data
 
     expected_final_metadata = tasks.collections.ChainMap(
-        {"upload_source": None}, audio_metadata, {"funkwhale": {}},
+        {"upload_source": None}, expected_forced_values, {"funkwhale": {}},
     )
     assert upload.import_status == "finished"
     get_track_from_import_metadata.assert_called_once_with(
-        expected_final_metadata, **expected_forced_values
+        expected_final_metadata,
+        attributed_to=channel.attributed_to,
+        **expected_forced_values
     )
 
+    assert upload.track.description.content_type == "text/markdown"
+    assert upload.track.description.text == import_metadata["description"]
+    assert upload.track.title == import_metadata["title"]
+    assert upload.track.position == import_metadata["position"]
+    assert upload.track.copyright == import_metadata["copyright"]
+    assert upload.track.get_tags() == import_metadata["tags"]
+    assert upload.track.artist == channel.artist
+    assert upload.track.attributed_to == channel.attributed_to
+    assert upload.track.attachment_cover == attachment
+
 
 def test_process_upload_uses_import_metadata_if_valid(factories, mocker):
     track = factories["music.Track"]()
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 6df25ea634abba206b3965293474357c3d5e838f..74fa3f6f8406f5df1bbbac4c97e6e186f067f330 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -723,6 +723,7 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f
             "source": "upload://test",
             "import_reference": "test",
             "library": library.uuid,
+            "import_metadata": '{"title": "foo"}',
         },
     )
 
@@ -735,6 +736,38 @@ def test_user_can_create_upload(logged_in_api_client, factories, mocker, audio_f
     assert upload.source == "upload://test"
     assert upload.import_reference == "test"
     assert upload.import_status == "pending"
+    assert upload.import_metadata == {"title": "foo"}
+    assert upload.track is None
+    m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk)
+
+
+def test_user_can_create_upload_in_channel(
+    logged_in_api_client, factories, mocker, audio_file
+):
+    actor = logged_in_api_client.user.create_actor()
+    channel = factories["audio.Channel"](attributed_to=actor)
+    url = reverse("api:v1:uploads-list")
+    m = mocker.patch("funkwhale_api.common.utils.on_commit")
+    album = factories["music.Album"](artist=channel.artist)
+    response = logged_in_api_client.post(
+        url,
+        {
+            "audio_file": audio_file,
+            "source": "upload://test",
+            "import_reference": "test",
+            "channel": channel.uuid,
+            "import_metadata": '{"title": "foo", "album": ' + str(album.pk) + "}",
+        },
+    )
+
+    assert response.status_code == 201
+
+    upload = channel.library.uploads.latest("id")
+
+    assert upload.source == "upload://test"
+    assert upload.import_reference == "test"
+    assert upload.import_status == "pending"
+    assert upload.import_metadata == {"title": "foo", "album": album.pk}
     assert upload.track is None
     m.assert_called_once_with(tasks.process_upload.delay, upload_id=upload.pk)
 
@@ -1318,3 +1351,106 @@ def test_detail_includes_description_key(
     response = logged_in_api_client.get(url)
 
     assert response.data["description"] is None
+
+
+def test_channel_owner_can_create_album(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    channel = factories["audio.Channel"](attributed_to=actor)
+    attachment = factories["common.Attachment"](actor=actor)
+    url = reverse("api:v1:albums-list")
+
+    data = {
+        "artist": channel.artist.pk,
+        "cover": attachment.uuid,
+        "title": "Hello world",
+        "release_date": "2019-01-02",
+        "tags": ["Hello", "World"],
+        "description": {"content_type": "text/markdown", "text": "hello world"},
+    }
+
+    response = logged_in_api_client.post(url, data, format="json")
+
+    assert response.status_code == 201
+
+    album = channel.artist.albums.get(title=data["title"])
+
+    assert (
+        response.data
+        == serializers.AlbumSerializer(album, context={"description": True}).data
+    )
+    assert album.attachment_cover == attachment
+    assert album.attributed_to == actor
+    assert album.release_date == datetime.date(2019, 1, 2)
+    assert album.get_tags() == ["Hello", "World"]
+    assert album.description.content_type == "text/markdown"
+    assert album.description.text == "hello world"
+
+
+def test_channel_owner_can_delete_album(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    channel = factories["audio.Channel"](attributed_to=actor)
+    album = factories["music.Album"](artist=channel.artist)
+    url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+    with pytest.raises(album.DoesNotExist):
+        album.refresh_from_db()
+
+
+def test_other_user_cannot_create_album(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    channel = factories["audio.Channel"]()
+    attachment = factories["common.Attachment"](actor=actor)
+    url = reverse("api:v1:albums-list")
+
+    data = {
+        "artist": channel.artist.pk,
+        "cover": attachment.uuid,
+        "title": "Hello world",
+        "release_date": "2019-01-02",
+        "tags": ["Hello", "World"],
+        "description": {"content_type": "text/markdown", "text": "hello world"},
+    }
+
+    response = logged_in_api_client.post(url, data, format="json")
+
+    assert response.status_code == 400
+
+
+def test_other_user_cannot_delete_album(factories, logged_in_api_client):
+    logged_in_api_client.user.create_actor()
+    channel = factories["audio.Channel"]()
+    album = factories["music.Album"](artist=channel.artist)
+    url = reverse("api:v1:albums-detail", kwargs={"pk": album.pk})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 404
+    album.refresh_from_db()
+
+
+def test_channel_owner_can_delete_track(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    channel = factories["audio.Channel"](attributed_to=actor)
+    track = factories["music.Track"](artist=channel.artist)
+    url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+    with pytest.raises(track.DoesNotExist):
+        track.refresh_from_db()
+
+
+def test_other_user_cannot_delete_track(factories, logged_in_api_client):
+    logged_in_api_client.user.create_actor()
+    channel = factories["audio.Channel"]()
+    track = factories["music.Track"](artist=channel.artist)
+    url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 404
+    track.refresh_from_db()
diff --git a/front/package.json b/front/package.json
index d05c57d10861009e373e32b1b090ad1b8da1a6e6..45b96fe413785d229d4f1d19365922c2b88f61f7 100644
--- a/front/package.json
+++ b/front/package.json
@@ -16,7 +16,7 @@
     "axios": "^0.18.0",
     "diff": "^4.0.1",
     "django-channels": "^1.1.6",
-    "fomantic-ui-css": "^2.7",
+    "fomantic-ui-css": "^2.8.3",
     "howler": "^2.0.14",
     "js-logger": "^1.4.1",
     "jwt-decode": "^2.2.0",
diff --git a/front/src/App.vue b/front/src/App.vue
index f53cc3a4a2cadefc824edf0a1aa0e3f56eac54d6..6900ccaf8dbecf01b6def1dd2df894961987990d 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -1,5 +1,5 @@
 <template>
-  <div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}]">
+  <div id="app" :key="String($store.state.instance.instanceUrl)" :class="[$store.state.ui.queueFocused ? 'queue-focused' : '', {'has-bottom-player': $store.state.queue.tracks.length > 0}, `is-${ $store.getters['ui/windowSize']}`]">
     <!-- here, we display custom stylesheets, if any -->
     <link
       v-for="url in customStylesheets"
@@ -33,6 +33,7 @@
         @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
       ></app-footer>
       <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
+      <channel-upload-modal v-if="$store.state.auth.authenticated"></channel-upload-modal>
       <filter-modal v-if="$store.state.auth.authenticated"></filter-modal>
       <report-modal></report-modal>
       <shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
@@ -57,6 +58,7 @@ export default {
     Player:  () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"),
     Queue:  () => import(/* webpackChunkName: "audio" */ "@/components/Queue"),
     PlaylistModal:  () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"),
+    ChannelUploadModal:  () => import(/* webpackChunkName: "auth-audio" */ "@/components/channels/UploadModal"),
     Sidebar:  () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"),
     AppFooter:  () => import(/* webpackChunkName: "core" */ "@/components/Footer"),
     ServiceMessages:  () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"),
@@ -393,6 +395,13 @@ export default {
     top: 0;
   }
 }
+.dimmed {
+  .ui.bottom-player {
+    @include media("<desktop") {
+      z-index: 0;
+    }
+  }
+}
 #app.queue-focused {
   .queue-not-focused {
     @include media("<desktop") {
diff --git a/front/src/EmbedFrame.vue b/front/src/EmbedFrame.vue
index b302c7e2d5af212109f19e95bb8b1b913e293893..609348899e0c8e811920bb32b7bff9cf636b02be 100644
--- a/front/src/EmbedFrame.vue
+++ b/front/src/EmbedFrame.vue
@@ -103,7 +103,7 @@
             </td>
             <td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></td>
             <td class="artist" :title="track.artist.name" ><div class="ellipsis">{{ track.artist.name }}</div></td>
-            <td class="album">
+            <td class="album" v-if="track.album">
               <div class="ellipsis " v-if="track.album" :title="track.album.title">{{ track.album.title }}</div>
             </td>
             <td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</td>
@@ -236,7 +236,7 @@ export default {
         this.fetchTracks({channel: id, playable: true, include_channels: 'true', ordering: "-creation_date"})
       }
       if (type === 'artist') {
-        this.fetchTracks({artist: id, playable: true, ordering: "-release_date,disc_number,position"})
+        this.fetchTracks({artist: id, playable: true, include_channels: 'true', ordering: "-release_date,disc_number,position"})
       }
       if (type === 'playlist') {
         this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`)
diff --git a/front/src/components/SetInstanceModal.vue b/front/src/components/SetInstanceModal.vue
index 07be79f4339f4b8a7d5cb37b97664e173efae516..a5ff00f8a4f13806ff6a168c3af8d02e7eed628a 100644
--- a/front/src/components/SetInstanceModal.vue
+++ b/front/src/components/SetInstanceModal.vue
@@ -35,7 +35,7 @@
       </form>
     </div>
     <div class="actions">
-      <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
+      <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
     </div>
   </modal>
 </template>
diff --git a/front/src/components/ShortcutsModal.vue b/front/src/components/ShortcutsModal.vue
index 097672f2caadce5073a694412ff27a58c9ddb051..8f3f41babf9d7a6f17d478cb260362806b572cfa 100644
--- a/front/src/components/ShortcutsModal.vue
+++ b/front/src/components/ShortcutsModal.vue
@@ -36,7 +36,7 @@
       </div>
     </section>
     <footer class="actions">
-      <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div>
+      <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></div>
     </footer>
   </modal>
 </template>
diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue
index 6cb1ae4165ede5954a005eb0e081d853e1366bad..8adbb9d2ce43b55f72b6d10c10b227567b1bec85 100644
--- a/front/src/components/audio/ChannelCard.vue
+++ b/front/src/components/audio/ChannelCard.vue
@@ -1,13 +1,13 @@
 <template>
   <div class="card app-card">
     <div
-      @click="$router.push({name: 'channels.detail', params: {id: object.uuid}})"
-      :class="['ui', 'head-image', 'padded image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
+      @click="$router.push({name: 'channels.detail', params: {id: urlId}})"
+      :class="['ui', 'head-image', {'circular': object.artist.content_category != 'podcast'}, {'padded': object.artist.content_category === 'podcast'}, 'image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
       <play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object.artist"></play-button>
     </div>
     <div class="content">
       <strong>
-        <router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: object.uuid}}">
+        <router-link class="discrete link" :title="object.artist.name" :to="{name: 'channels.detail', params: {id: urlId}}">
           {{ object.artist.name }}
         </router-link>
       </strong>
@@ -49,6 +49,15 @@ export default {
         return null
       }
       return url
+    },
+    urlId () {
+      if (this.object.actor && this.object.actor.is_local) {
+        return this.object.actor.preferred_username
+      } else if (this.object.actor) {
+        return this.object.actor.full_username
+      } else {
+        return this.object.uuid
+      }
     }
   }
 }
diff --git a/front/src/components/audio/ChannelEntryCard.vue b/front/src/components/audio/ChannelEntryCard.vue
index 1bdbd6839420f55a63586ec8806a04d663bb08c3..6d1e688bc649e91c690810c642fe53119ca81aac 100644
--- a/front/src/components/audio/ChannelEntryCard.vue
+++ b/front/src/components/audio/ChannelEntryCard.vue
@@ -1,6 +1,6 @@
 <template>
   <div class="channel-entry-card">
-    <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
+    <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover && cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
     <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png">
     <div class="content">
       <strong>
@@ -39,6 +39,9 @@ export default {
       return url
     },
     cover () {
+      if (this.entry.cover) {
+        return this.entry.cover
+      }
       if (this.entry.album && this.entry.album.cover) {
         return this.entry.album.cover
       }
diff --git a/front/src/components/audio/ChannelForm.vue b/front/src/components/audio/ChannelForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5f6f59d54a3e4ffccd62e0f17fdcf5c89dc3ba32
--- /dev/null
+++ b/front/src/components/audio/ChannelForm.vue
@@ -0,0 +1,299 @@
+<template>
+  <form class="ui form" @submit.prevent.stop="submit">
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header"><translate translate-context="Content/*/Error message.Title">Error while saving channel</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <template v-if="metadataChoices">
+      <div v-if="creating && step === 1" class="ui grouped channel-type required field">
+        <label>
+          <translate translate-context="Content/Channel/Paragraph">What this channel will be used for?</translate>
+        </label>
+        <div class="ui hidden divider"></div>
+        <div class="field">
+          <div :class="['ui', 'radio', 'checkbox', {selected: choice.value == newValues.content_category}]" v-for="choice in categoryChoices">
+            <input type="radio" name="channel-category" :id="`category-${choice.value}`" :value="choice.value" v-model="newValues.content_category">
+            <label :for="`category-${choice.value}`">
+              <span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]"></span>
+              <strong>{{ choice.label }}</strong>
+              <div class="ui small hidden divider"></div>
+              {{ choice.helpText }}
+            </label>
+          </div>
+        </div>
+      </div>
+      <template v-if="!creating || step === 2">
+        <div class="ui required field">
+          <label for="channel-name">
+            <translate translate-context="Content/Channel/*">Name</translate>
+          </label>
+          <input type="text" required v-model="newValues.name" :placeholder="labels.namePlaceholder">
+        </div>
+        <div class="ui required field">
+          <label for="channel-username">
+            <translate translate-context="Content/Channel/*">Social Network Name</translate>
+          </label>
+          <div class="ui left labeled input">
+            <div class="ui basic label">@</div>
+            <input type="text" :required="creating" :disabled="!creating" :placeholder="labels.usernamePlaceholder" v-model="newValues.username">
+          </div>
+          <template v-if="creating">
+            <div class="ui small hidden divider"></div>
+            <p>
+              <translate translate-context="Content/Channels/Paragraph">Used in URLs and to follow this channel on the federation. You cannot change it afterwards.</translate>
+            </p>
+          </template>
+        </div>
+        <div class="six wide column">
+          <attachment-input
+            v-model="newValues.cover"
+            :required="false"
+            :image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
+            @delete="newValues.cover = null">
+            <translate translate-context="Content/Channel/*" slot="label">Channel Picture</translate>
+          </attachment-input>
+
+        </div>
+        <div class="ui small hidden divider"></div>
+        <div class="ui stackable grid row">
+          <div class="ten wide column">
+            <div class="ui field">
+              <label for="channel-tags">
+                <translate translate-context="*/*/*">Tags</translate>
+              </label>
+              <tags-selector
+                v-model="newValues.tags"
+                id="channel-tags"
+                :required="false"></tags-selector>
+            </div>
+          </div>
+          <div class="six wide column" v-if="newValues.content_category === 'podcast'">
+            <div class="ui required field">
+              <label for="channel-language">
+                <translate translate-context="*/*/*">Language</translate>
+              </label>
+              <select
+                name="channel-language"
+                id="channel-language"
+                v-model="newValues.metadata.language"
+                required
+                class="ui search selection dropdown">
+                <option v-for="v in metadataChoices.language" :value="v.value">{{ v.label }}</option>
+              </select>
+            </div>
+          </div>
+        </div>
+        <div class="ui small hidden divider"></div>
+        <div class="ui field">
+          <label for="channel-name">
+            <translate translate-context="*/*/*">Description</translate>
+          </label>
+          <content-form v-model="newValues.description"></content-form>
+        </div>
+        <div class="ui two fields" v-if="newValues.content_category === 'podcast'">
+          <div class="ui required field">
+            <label for="channel-itunes-category">
+              <translate translate-context="*/*/*">Category</translate>
+            </label>
+            <select
+              name="itunes-category"
+              id="itunes-category"
+              v-model="newValues.metadata.itunes_category"
+              required
+              class="ui dropdown">
+              <option v-for="v in metadataChoices.itunes_category" :value="v.value">{{ v.label }}</option>
+            </select>
+          </div>
+          <div class="ui field">
+            <label for="channel-itunes-category">
+              <translate translate-context="*/*/*">Subcategory</translate>
+            </label>
+            <select
+              name="itunes-category"
+              id="itunes-category"
+              v-model="newValues.metadata.itunes_subcategory"
+              :disabled="!newValues.metadata.itunes_category"
+              class="ui dropdown">
+              <option v-for="v in itunesSubcategories" :value="v">{{ v }}</option>
+            </select>
+          </div>
+        </div>
+      </template>
+    </template>
+    <div v-else class="ui active inverted dimmer">
+      <div class="ui text loader">
+        <translate translate-context="*/*/*">Loading</translate>
+      </div>
+    </div>
+  </form>
+</template>
+
+<script>
+import axios from 'axios'
+
+import AttachmentInput from '@/components/common/AttachmentInput'
+import TagsSelector from '@/components/library/TagsSelector'
+
+function slugify(text) {
+  return text.toString().toLowerCase()
+    .replace(/\s+/g, '')           // Remove spaces
+    .replace(/[^\w]+/g, '')        // Remove all non-word chars
+}
+
+export default {
+  props: {
+    object: {type: Object, required: false, default: null},
+    step: {type: Number, required: false, default: 1},
+  },
+  components: {
+    AttachmentInput,
+    TagsSelector
+  },
+
+  created () {
+    this.fetchMetadataChoices()
+  },
+  data () {
+    let oldValues = {}
+    if (this.object) {
+      oldValues.metadata = {...(this.object.metadata || {})}
+      oldValues.name = this.object.artist.name
+      oldValues.description = this.object.artist.description
+      oldValues.cover = this.object.artist.cover
+      oldValues.tags = this.object.artist.tags
+      oldValues.content_category = this.object.artist.content_category
+      oldValues.username = this.object.actor.preferred_username
+    }
+    return {
+      isLoading: false,
+      errors: [],
+      metadataChoices: null,
+      newValues: {
+        name: oldValues.name || "",
+        username: oldValues.username || "",
+        tags: oldValues.tags || [],
+        description: (oldValues.description || {}).text || "",
+        cover: (oldValues.cover || {}).uuid || null,
+        content_category: oldValues.content_category || "podcast",
+        metadata: oldValues.metadata || {},
+      }
+    }
+  },
+  computed: {
+    creating () {
+      return this.object === null
+    },
+    categoryChoices () {
+      return [
+        {
+          value: "podcast",
+          label: this.$pgettext('*/*/*', "Podcasts"),
+          helpText: this.$pgettext('Content/Channels/Help', "Host your episodes and keep your community updated."),
+        },
+        {
+          value: "music",
+          label: this.$pgettext('*/*/*', "Artist discography"),
+          helpText: this.$pgettext('Content/Channels/Help', "Publish music  you make as a nice discography of albums and singles."),
+        }
+      ]
+    },
+    itunesSubcategories () {
+      for (let index = 0; index < this.metadataChoices.itunes_category.length; index++) {
+        const element = this.metadataChoices.itunes_category[index];
+        if (element.value === this.newValues.metadata.itunes_category) {
+          return element.children || []
+        }
+      }
+      return []
+    },
+    labels () {
+      return {
+        namePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "Awesome channel name"),
+        usernamePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "awesomechannelname"),
+      }
+    },
+    submittable () {
+      let v = this.newValues.name && this.newValues.username
+      if (this.newValues.content_category === 'podcast') {
+        v = v && this.newValues.metadata.itunes_category && this.newValues.metadata.language
+      }
+      return !!v
+    }
+  },
+  methods: {
+    fetchMetadataChoices () {
+      let self = this
+      axios.get('channels/metadata-choices').then((response) => {
+        self.metadataChoices = response.data
+      }, error => {
+        self.errors = error.backendErrors
+      })
+    },
+    submit () {
+      this.isLoading = true
+      let self = this
+      let handler = this.creating ? axios.post : axios.patch
+      let url = this.creating ? `channels/` : `channels/${this.object.uuid}`
+      let payload = {
+        name: this.newValues.name,
+        username: this.newValues.username,
+        tags: this.newValues.tags,
+        content_category: this.newValues.content_category,
+        cover: this.newValues.cover,
+        metadata: this.newValues.metadata,
+      }
+      if (this.newValues.description) {
+        payload.description = {
+          content_type: 'text/markdown',
+          text: this.newValues.description,
+        }
+      } else {
+        payload.description = null
+      }
+
+      handler(url, payload).then((response) => {
+        self.isLoading = false
+        if (self.creating) {
+          self.$emit('created', response.data)
+        } else {
+          self.$emit('updated', response.data)
+        }
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+        self.$emit('errored', self.errors)
+      })
+    }
+  },
+  watch: {
+    "newValues.name" (v) {
+      if (this.creating) {
+        this.newValues.username = slugify(v)
+      }
+    },
+    "newValues.metadata.itunes_category" (v) {
+      this.newValues.metadata.itunes_subcategory = null
+    },
+    "newValues.content_category": {
+      handler (v) {
+        this.$emit("category", v)
+      },
+      immediate: true
+    },
+    isLoading: {
+      handler (v) {
+        this.$emit("loading", v)
+      },
+      immediate: true
+    },
+    submittable: {
+      handler (v) {
+        this.$emit("submittable", v)
+      },
+      immediate: true
+    },
+  }
+}
+</script>
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index 2c74746727dc198ca0f66f56ffe3145d4019eae1..a4bb95692803e415a1e43ca683c364de6d2af30d 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -1,5 +1,5 @@
 <template>
-  <span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}]">
+  <span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button']">
     <button
       v-if="!dropdownOnly"
       :title="labels.playNow"
@@ -102,7 +102,9 @@ export default {
       }
       if (this.track) {
         return this.track.uploads && this.track.uploads.length > 0
-      } else if (this.artist) {
+      } else if (this.artist && this.artist.tracks_count) {
+        return this.artist.tracks_count > 0
+      }  else if (this.artist && this.artist.albums) {
         return this.artist.albums.filter((a) => {
           return a.is_playable === true
         }).length > 0
@@ -189,10 +191,10 @@ export default {
             resolve(tracks)
           })
         } else if (self.artist) {
-          let params = {'artist': self.artist.id, 'ordering': 'album__release_date,disc_number,position'}
+          let params = {'artist': self.artist.id, include_channels: 'true', 'ordering': 'album__release_date,disc_number,position'}
           self.getTracksPage(1, params, resolve)
         } else if (self.album) {
-          let params = {'album': self.album.id, 'ordering': 'disc_number,position'}
+          let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'}
           self.getTracksPage(1, params, resolve)
         }
       })
@@ -255,9 +257,27 @@ export default {
             // works as expected
             self.$refs[$el.data('ref')].click()
             jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
+          },
+        })
+        jQuery(this.$el).find('.ui.dropdown').dropdown('show', function () {
+          // little magic to ensure the menu is always visible in the viewport
+          // By default, try to diplay it on the right if there is enough room
+          let menu = jQuery(self.$el).find('.ui.dropdown').find(".menu")
+          let viewportOffset = menu.get(0).getBoundingClientRect();
+          let left = viewportOffset.left;
+          let viewportWidth = document.documentElement.clientWidth
+          let rightOverflow = viewportOffset.right - viewportWidth
+          let leftOverflow = -viewportOffset.left
+          let offset = 0
+          if (rightOverflow > 0) {
+            offset = -rightOverflow - 5
+            menu.css({cssText: `left: ${offset}px !important;`});
+          }
+          else if (leftOverflow > 0) {
+            offset = leftOverflow  + 5
+            menu.css({cssText: `right: -${offset}px !important;`});
           }
         })
-        jQuery(this.$el).find('.ui.dropdown').dropdown('show')
       })
     }
   }
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index ff1e78b3d09070f68f4a0edc4879fd916e36e026..8ad1b3c140a28288e86de08aeb9b5bfba1b73ece 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -48,7 +48,9 @@
             @input="submitAvatar($event)"
             :initial-value="initialAvatar"
             :required="false"
-            @delete="avatar = {uuid: null}"></attachment-input>
+            @delete="avatar = {uuid: null}">
+            <translate translate-context="Content/Channel/*" slot="label">Avatar</translate>
+            </attachment-input>
         </div>
       </section>
 
@@ -79,8 +81,7 @@
             <password-input required v-model="new_password" />
           </div>
           <dangerous-button
-            color="yellow"
-            :class="['ui', {'loading': isLoading}, 'button']"
+            :class="['ui', {'loading': isLoading}, 'yellow', 'button']"
             :action="submitPassword">
             <translate translate-context="Content/Settings/Button.Label">Change password</translate>
             <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Change your password?</translate></p>
@@ -177,7 +178,7 @@
               </td>
               <td>
                 <dangerous-button
-                  class="ui tiny basic button"
+                  class="ui tiny basic red button"
                   @confirm="revokeApp(app.client_id)">
                   <translate translate-context="*/*/*/Verb">Revoke</translate>
                   <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Revoke access for application "%{ application }"?</p>
@@ -236,7 +237,7 @@
                   <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
                 </router-link>
                 <dangerous-button
-                  class="ui tiny basic button"
+                  class="ui tiny basic red button"
                   @confirm="deleteApp(app.client_id)">
                   <translate translate-context="*/*/*/Verb">Delete</translate>
                   <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Delete application "%{ application }"?</p>
@@ -282,7 +283,7 @@
             <password-input required v-model="password" />
           </div>
           <dangerous-button
-            :class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'button']"
+            :class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, 'red', 'button']"
             :action="deleteAccount">
             <translate translate-context="*/*/Button.Label">Delete my account…</translate>
             <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Do you want to delete your account?</translate></p>
diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue
index fdd9f5e107b2de27b28cb5099b56751b9be180a6..67621bf8dc2e6001a98def6dc56c3abebd01ad7c 100644
--- a/front/src/components/auth/SubsonicTokenForm.vue
+++ b/front/src/components/auth/SubsonicTokenForm.vue
@@ -33,8 +33,7 @@
       </div>
       <dangerous-button
         v-if="token"
-        color="grey"
-        :class="['ui', {'loading': isLoading}, 'button']"
+        :class="['ui', {'loading': isLoading}, 'grey', 'button']"
         :action="requestNewToken">
         <translate translate-context="*/Settings/Button.Label/Verb">Request a new password</translate>
         <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Request a new Subsonic API password?</translate></p>
@@ -48,8 +47,7 @@
         @click="requestNewToken"><translate translate-context="Content/Settings/Button.Label/Verb">Request a password</translate></button>
         <dangerous-button
           v-if="token"
-          color="yellow"
-          :class="['ui', {'loading': isLoading}, 'button']"
+          :class="['ui', {'loading': isLoading}, 'yellow', 'button']"
           :action="disable">
           <translate translate-context="Content/Settings/Button.Label/Verb">Disable Subsonic access</translate>
           <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Disable Subsonic API access?</translate></p>
diff --git a/front/src/components/channels/AlbumForm.vue b/front/src/components/channels/AlbumForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e224b7addf0c8f48f816a4abdb545115a7a2f190
--- /dev/null
+++ b/front/src/components/channels/AlbumForm.vue
@@ -0,0 +1,71 @@
+<template>
+  <form @submit.stop.prevent :class="['ui', {loading: isLoading}, 'form']">
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header"><translate translate-context="Content/*/Error message.Title">Error while creating</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div class="ui required field">
+      <label for="album-title">
+        <translate translate-context="*/*/*/Noun">Title</translate>
+      </label>
+      <input type="text" v-model="values.title">
+    </div>
+    </div>
+  </form>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  props: {
+    channel: {type: Object, required: true},
+  },
+  components: {},
+  data () {
+    return {
+      errors: [],
+      isLoading: false,
+      values: {
+        title: "",
+      },
+    }
+  },
+  computed: {
+    submittable () {
+      return this.values.title.length > 0
+    }
+  },
+  methods: {
+
+    submit () {
+      let self = this
+      self.isLoading = true
+      self.errors = []
+      let payload = {
+        ...this.values,
+        artist: this.channel.artist.id,
+      }
+      return axios.post('albums/', payload).then(
+        response => {
+          self.isLoading = false
+          self.$emit("created")
+        },
+        error => {
+          self.errors = error.backendErrors
+          self.isLoading = false
+        }
+      )
+    }
+  },
+  watch: {
+    submittable (v) {
+      this.$emit("submittable", v)
+    },
+    isLoading (v) {
+      this.$emit("loading", v)
+    }
+  }
+}
+</script>
diff --git a/front/src/components/channels/AlbumModal.vue b/front/src/components/channels/AlbumModal.vue
new file mode 100644
index 0000000000000000000000000000000000000000..197bafe2a99c0c77a2794ca8a9c60b3a7b6cae5e
--- /dev/null
+++ b/front/src/components/channels/AlbumModal.vue
@@ -0,0 +1,48 @@
+<template>
+  <modal class="small" :show.sync="show">
+    <div class="header">
+      <translate key="1" v-if="channel.content_category === 'podcasts'" translate-context="Popup/Channels/Title/Verb">New serie</translate>
+      <translate key="2" v-else translate-context="Popup/Channels/Title">New album</translate>
+    </div>
+    <div class="scrolling content">
+      <channel-album-form
+        ref="albumForm"
+        @loading="isLoading = $event"
+        @submittable="submittable = $event"
+        @created="$emit('created', $event)"
+        :channel="channel"></channel-album-form>
+    </div>
+    <div class="actions">
+      <button class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button>
+      <button :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="!submittable" @click.stop.prevent="$refs.albumForm.submit()">
+        <translate translate-context="*/*/Button.Label">Create</translate>
+      </button>
+    </div>
+  </modal>
+</template>
+
+<script>
+import Modal from '@/components/semantic/Modal'
+import ChannelAlbumForm from '@/components/channels/AlbumForm'
+
+export default {
+  props: ['channel'],
+  components: {
+    Modal,
+    ChannelAlbumForm
+  },
+  data () {
+    return {
+      isLoading: false,
+      submittable: false,
+      show: false,
+    }
+  },
+  watch: {
+    show () {
+      this.isLoading = false
+      this.submittable = false
+    }
+  }
+}
+</script>
diff --git a/front/src/components/channels/AlbumSelect.vue b/front/src/components/channels/AlbumSelect.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ed4b493e885937a078bedba3426ec004ea73a614
--- /dev/null
+++ b/front/src/components/channels/AlbumSelect.vue
@@ -0,0 +1,49 @@
+<template>
+  <div>
+    <label for="album-dropdown">
+      <translate v-if="channel && channel.artist.content_category === 'podcast'" key="1" translate-context="*/*/*">Serie</translate>
+      <translate v-else key="2" translate-context="*/*/*">Album</translate>
+    </label>
+    <select id="album-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown">
+      <option value="">
+        <translate translate-context="*/*/*">None</translate>
+      </option>
+      <option v-for="album in albums" :key="album.id" :value="album.id">
+        {{ album.title }} (<translate translate-context="*/*/*" :translate-params="{count: album.tracks.length}" :translate-n="album.tracks.length" translate-plural="%{ count } tracks">%{ count } track</translate>)
+      </option>
+    </select>
+  </div>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  props: ['value', 'channel'],
+  data () {
+    return {
+      albums: [],
+      isLoading: false,
+    }
+  },
+  async created () {
+    await this.fetchData()
+  },
+  methods: {
+    async fetchData () {
+      this.albums = []
+      if (!this.channel) {
+        return
+      }
+      this.isLoading = true
+      let response = await axios.get('albums/', {params: {artist: this.channel.artist.id, include_channels: 'true'}})
+      this.albums = response.data.results
+      this.isLoading = false
+    },
+  },
+  watch: {
+    async channel () {
+      await this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/components/channels/LicenseSelect.vue b/front/src/components/channels/LicenseSelect.vue
new file mode 100644
index 0000000000000000000000000000000000000000..98317b16c49db614a7ffee215adfac0af2103019
--- /dev/null
+++ b/front/src/components/channels/LicenseSelect.vue
@@ -0,0 +1,69 @@
+<template>
+  <div>
+    <label for="license-dropdown">
+      <translate translate-context="Content/*/*/Noun">License</translate>
+    </label>
+    <select id="license-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown">
+      <option value="">
+        <translate translate-context="*/*/*">None</translate>
+      </option>
+      <option v-for="l in featuredLicenses" :key="l.code" :value="l.code">{{ l.name }}</option>
+    </select>
+    <p class="help" v-if="value">
+      <div class="ui very small hidden divider"></div>
+      <a :href="currentLicense.url"  v-if="value" target="_blank" rel="noreferrer noopener">
+        <translate translate-context="Content/*/*">About this license</translate>
+      </a>
+    </p>
+  </div>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  props: ['value'],
+  data () {
+    return {
+      availableLicenses: [],
+      featuredLicensesIds: [
+        'cc0-1.0',
+        'cc-by-4.0',
+        'cc-by-sa-4.0',
+        'cc-by-nc-4.0',
+        'cc-by-nc-sa-4.0',
+        'cc-by-nc-nd-4.0',
+        'cc-by-nd-4.0',
+      ],
+      isLoading: false,
+    }
+  },
+  async created () {
+    await this.fetchLicenses()
+  },
+  computed: {
+    featuredLicenses () {
+      let self = this
+      return this.availableLicenses.filter((l) => {
+        return self.featuredLicensesIds.indexOf(l.code) > -1
+      })
+    },
+    currentLicense () {
+      let self = this
+      if (this.value) {
+        return this.availableLicenses.filter((l) => {
+          return l.code === self.value
+        })[0]
+
+      }
+    }
+  },
+  methods: {
+    async fetchLicenses () {
+      this.isLoading = true
+      let response = await axios.get('licenses/')
+      this.availableLicenses = response.data.results
+      this.isLoading = false
+    },
+  },
+}
+</script>
diff --git a/front/src/components/channels/UploadForm.vue b/front/src/components/channels/UploadForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..95105437869d3be7f0f56567e4322b4fbfe0f38f
--- /dev/null
+++ b/front/src/components/channels/UploadForm.vue
@@ -0,0 +1,528 @@
+<template>
+  <form @submit.stop.prevent :class="['ui', {loading: isLoadingStep1}, 'form']">
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header"><translate translate-context="Content/*/Error message.Title">Error while publishing</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div :class="['ui', 'required', {hidden: step > 1}, 'field']">
+      <label for="channel-dropdown">
+        <translate translate-context="*/*/*">Channel</translate>
+      </label>
+      <div id="channel-dropdown" class="ui search normal selection dropdown">
+        <div class="text"></div>
+        <i class="dropdown icon"></i>
+      </div>
+    </div>
+    <album-select v-model.number="values.album" :channel="selectedChannel" :class="['ui', {hidden: step > 1}, 'field']"></album-select>
+    <license-select v-model="values.license" :class="['ui', {hidden: step > 1}, 'field']"></license-select>
+    <div :class="['ui', {hidden: step > 1}, 'message']">
+      <div class="content">
+        <p>
+          <i class="copyright icon"></i>
+          <translate translate-context="Content/Channels/Popup.Paragraph">Add a license to your upload to ensure some freedoms to your public.</translate>
+        </p>
+      </div>
+    </div>
+    <template v-if="step >= 2 && step < 4">
+      <div class="ui warning message" v-if="remainingSpace === 0">
+        <div class="content">
+          <p>
+            <i class="warning icon"></i>
+            <translate translate-context="Content/Library/Paragraph">You don't have any space left to upload your files. Please contact the moderators.</translate>
+          </p>
+        </div>
+      </div>
+      <template v-else>
+        <div class="ui visible info message" v-if="step === 2 && draftUploads && draftUploads.length > 0 && includeDraftUploads === null">
+          <p>
+            <i class="redo icon"></i>
+            <translate translate-context="Popup/Channels/Paragraph">You have some draft uploads pending publication.</translate>
+          </p>
+          <button @click.stop.prevent="includeDraftUploads = false" class="ui basic button">
+            <translate translate-context="*/*/*">Ignore</translate>
+          </button>
+          <button @click.stop.prevent="includeDraftUploads = true" class="ui basic button">
+            <translate translate-context="*/*/*">Resume</translate>
+          </button>
+        </div>
+        <div v-if="uploadedFiles.length > 0" :class="[{hidden: step === 3}]">
+          <div class="channel-file" v-for="(file, idx) in uploadedFiles">
+            <div class="content">
+              <div role="button"
+                v-if="file.response.uuid"
+                @click.stop.prevent="selectedUploadId = file.response.uuid"
+                class="ui basic icon button"
+                :title="labels.editTitle">
+                <i class="pencil icon"></i>
+              </div>
+              <div
+                v-if="file.error"
+                @click.stop.prevent="selectedUploadId = file.response.uuid"
+                class="ui basic red icon label"
+                :title="file.error">
+                <i class="warning sign icon"></i>
+              </div>
+              <div v-else-if="file.active" class="ui active slow inline loader"></div>
+            </div>
+            <h4 class="ui header">
+              <template v-if="file.metadata.title">
+                {{ file.metadata.title }}
+              </template>
+              <template v-else>
+                {{ file.name }}
+              </template>
+              <div class="sub header">
+                <template v-if="file.response.uuid">
+                  {{ file.size | humanSize }}
+                  <template v-if="file.response.duration">
+                    · <human-duration :duration="file.response.duration"></human-duration>
+                  </template>
+                </template>
+                <template v-else>
+                  <translate key="1" v-if="file.active" translate-context="Channels/*/*">Uploading</translate>
+                  <translate key="2" v-else-if="file.error" translate-context="Channels/*/*">Errored</translate>
+                  <translate key="3" v-else translate-context="Channels/*/*">Pending</translate>
+                  · {{ file.size | humanSize }}
+                  · {{ parseInt(file.progress) }}%
+                </template>
+                · <a @click.stop.prevent="remove(file)">
+                  <translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
+                </a>
+                <template v-if="file.error"> ·
+                  <a @click.stop.prevent="retry(file)">
+                    <translate translate-context="*/*/*">Retry</translate>
+                  </a>
+                </template>
+              </div>
+            </h4>
+          </div>
+        </div>
+        <upload-metadata-form
+          :key="selectedUploadId"
+          v-if="selectedUpload"
+          :upload="selectedUpload"
+          :values="uploadImportData[selectedUploadId]"
+          @values="setDynamic('uploadImportData', selectedUploadId, $event)"></upload-metadata-form>
+        <div class="ui message" v-if="step === 2">
+          <div class="content">
+            <p>
+              <i class="info icon"></i>
+              <translate translate-context="Content/Library/Paragraph" :translate-params="{extensions: $store.state.ui.supportedExtensions.join(', ')}">Supported extensions: %{ extensions }</translate>
+            </p>
+          </div>
+        </div>
+        <file-upload-widget
+          :class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]"
+          :post-action="uploadUrl"
+          :multiple="true"
+          :data="baseImportMetadata"
+          :drop="true"
+          :extensions="$store.state.ui.supportedExtensions"
+          :value="files"
+          @input="updateFiles"
+          name="audio_file"
+          :thread="1"
+          @input-file="inputFile"
+          ref="upload">
+          <div>
+            <i class="upload icon"></i>&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>
diff --git a/front/src/components/channels/UploadMetadataForm.vue b/front/src/components/channels/UploadMetadataForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8824e6e48a2cd6293200145a76cafaeafa99d16f
--- /dev/null
+++ b/front/src/components/channels/UploadMetadataForm.vue
@@ -0,0 +1,72 @@
+<template>
+  <div :class="['ui', {loading: isLoading}, 'form']">
+    <div class="ui required field">
+      <label for="upload-title">
+        <translate translate-context="*/*/*/Noun">Title</translate>
+      </label>
+      <input type="text" v-model="newValues.title">
+    </div>
+    <attachment-input
+      v-model="newValues.cover"
+      :required="false"
+      @delete="newValues.cover = null">
+      <translate translate-context="Content/Channel/*" slot="label">Track Picture</translate>
+    </attachment-input>
+    <div class="ui small hidden divider"></div>
+    <div class="ui two fields">
+      <div class="ui field">
+        <label for="upload-tags">
+          <translate translate-context="*/*/*/Noun">Tags</translate>
+        </label>
+        <tags-selector
+          v-model="newValues.tags"
+          id="upload-tags"
+          :required="false"></tags-selector>
+      </div>
+      <div class="ui field">
+        <label for="upload-position">
+          <translate translate-context="*/*/*/Short, Noun">Position</translate>
+        </label>
+        <input type="number" min="1" step="1" v-model="newValues.position">
+      </div>
+    </div>
+    <div class="ui field">
+      <label for="upload-description">
+        <translate translate-context="*/*/*">Description</translate>
+      </label>
+      <content-form v-model="newValues.description" field-id="upload-description"></content-form>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import TagsSelector from '@/components/library/TagsSelector'
+import AttachmentInput from '@/components/common/AttachmentInput'
+
+export default {
+  props: ['upload', 'values'],
+  components: {
+    TagsSelector,
+    AttachmentInput
+  },
+  data () {
+    return {
+      newValues: {...this.values} || this.upload.import_metadata
+    }
+  },
+  computed: {
+    isLoading ()  {
+      return !!this.metadata
+    }
+  },
+  watch: {
+    newValues: {
+      handler (v) {
+        this.$emit('values', v)
+      },
+      immediate: true
+    },
+  }
+}
+</script>
diff --git a/front/src/components/channels/UploadModal.vue b/front/src/components/channels/UploadModal.vue
new file mode 100644
index 0000000000000000000000000000000000000000..98733ee45a1d34696efe75ed98ac8ee32962e7d0
--- /dev/null
+++ b/front/src/components/channels/UploadModal.vue
@@ -0,0 +1,119 @@
+<template>
+  <modal class="small" @update:show="update" :show="$store.state.channels.showUploadModal">
+    <div class="header">
+      <translate key="1" v-if="step === 1" translate-context="Popup/Channels/Title/Verb">Publish audio</translate>
+      <translate key="2" v-else-if="step === 2" translate-context="Popup/Channels/Title">Files to upload</translate>
+      <translate key="3" v-else-if="step === 3" translate-context="Popup/Channels/Title">Upload details</translate>
+      <translate key="4" v-else-if="step === 4" translate-context="Popup/Channels/Title">Processing uploads</translate>
+    </div>
+    <div class="scrolling content">
+      <channel-upload-form
+        ref="uploadForm"
+        @step="step = $event"
+        @loading="isLoading = $event"
+        @published="$store.commit('channels/publish', $event)"
+        @status="statusData = $event"
+        @submittable="submittable = $event"
+        :channel="$store.state.channels.uploadModalConfig.channel"></channel-upload-form>
+    </div>
+    <div class="actions">
+      <div class="left floated text left align">
+        <template v-if="statusData && step >= 2">
+          {{ statusInfo.join(' · ') }}
+        </template>
+        <div class="ui very small hidden divider"></div>
+        <template v-if="statusData && statusData.quotaStatus">
+          <translate translate-context="Content/Library/Paragraph">Remaining storage space:</translate>
+          {{ (statusData.quotaStatus.remaining * 1000 * 1000) - statusData.uploadedSize | humanSize }}
+        </template>
+      </div>
+      <div class="ui hidden clearing divider mobile-only"></div>
+      <button class="ui basic cancel button" v-if="step === 1"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button>
+      <button class="ui basic button" v-else-if="step < 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Previous step</translate></button>
+      <button class="ui basic button" v-else-if="step === 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Update</translate></button>
+      <button v-if="step === 1" class="ui primary button" @click.stop.prevent="$refs.uploadForm.step += 1">
+        <translate translate-context="*/*/Button.Label">Next step</translate>
+      </button>
+      <div class="ui primary buttons" v-if="step === 2">
+        <button
+          :class="['ui', 'primary button', {loading: isLoading}]"
+          type="submit"
+          :disabled="!statusData || !statusData.canSubmit"
+          @click.prevent.stop="$refs.uploadForm.publish">
+          <translate translate-context="*/Channels/Button.Label">Publish</translate>
+        </button>
+        <button class="ui floating dropdown icon button" ref="dropdown" v-dropdown :disabled="!statusData || !statusData.canSubmit">
+          <i class="dropdown icon"></i>
+          <div class="menu">
+            <div
+              role="button"
+              @click="update(false)"
+              class="basic item">
+              <translate translate-context="Content/*/Button.Label/Verb">Finish later</translate>
+            </div>
+          </div>
+        </button>
+      </div>
+      <button class="ui basic cancel button" @click="update(false)" v-if="step === 4"><translate translate-context="*/*/Button.Label/Verb">Close</translate></button>
+    </div>
+  </modal>
+</template>
+
+<script>
+import Modal from '@/components/semantic/Modal'
+import ChannelUploadForm from '@/components/channels/UploadForm'
+import {humanSize} from '@/filters'
+
+export default {
+  components: {
+    Modal,
+    ChannelUploadForm
+  },
+  data () {
+    return {
+      step: 1,
+      isLoading: false,
+      submittable: true,
+      statusData: null,
+    }
+  },
+  methods: {
+    update (v) {
+      this.$store.commit('channels/showUploadModal', {show: v})
+    },
+  },
+  computed: {
+    labels () {
+      return {}
+    },
+    statusInfo () {
+      if (!this.statusData) {
+        return []
+      }
+      let info = []
+      if (this.statusData.totalSize) {
+        info.push(humanSize(this.statusData.totalSize))
+      }
+      if (this.statusData.totalFiles) {
+        let msg = this.$npgettext('*/*/*', '%{ count } file', '%{ count } files', this.statusData.totalFiles)
+        info.push(
+          this.$gettextInterpolate(msg, {count: this.statusData.totalFiles}),
+        )
+      }
+      if (this.statusData.progress) {
+        info.push(`${this.statusData.progress}%`)
+      }
+      if (this.statusData.speed) {
+        info.push(`${humanSize(this.statusData.speed)}/s`)
+      }
+      return info
+
+    }
+  },
+  watch: {
+    '$store.state.route.path' () {
+      this.$store.commit('channels/showUploadModal', {show: false})
+    },
+  }
+}
+</script>
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue
index 1a43a5f9ff1ba4ea1e87ff61992458e8c947f0b1..83033138218a2ddaaed94243d6b47278caf7a7cf 100644
--- a/front/src/components/common/ActionTable.vue
+++ b/front/src/components/common/ActionTable.vue
@@ -31,7 +31,6 @@
                   <dangerous-button
                     v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
                     :confirm-color="currentAction.confirmColor || 'green'"
-                    color=""
                     @confirm="launchAction">
                     <translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate>
                     <p slot="modal-header">
diff --git a/front/src/components/common/AttachmentInput.vue b/front/src/components/common/AttachmentInput.vue
index 47b3253a25e4975ce7ab30efefe11d190d7d4965..dccb4eaa16a90916331c711b41eb75cb097ec93c 100644
--- a/front/src/components/common/AttachmentInput.vue
+++ b/front/src/components/common/AttachmentInput.vue
@@ -1,25 +1,33 @@
 <template>
-  <div>
+  <div class="ui form">
     <div v-if="errors.length > 0" class="ui negative message">
       <div class="header"><translate translate-context="Content/*/Error message.Title">Your attachment cannot be saved</translate></div>
       <ul class="list">
         <li v-for="error in errors">{{ error }}</li>
       </ul>
     </div>
-    <div class="ui stackable two column grid">
-      <div class="column" v-if="value && value === initialValue">
-        <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">Current file</translate></h3>
-        <img class="ui image" v-if="value" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" />
-      </div>
-      <div class="column" v-else-if="attachment">
-        <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3>
-        <img class="ui image" v-if="attachment && attachment.square_crop" :src="$store.getters['instance/absoluteUrl'](attachment.medium_square_crop)" />
-      </div>
-      <div class="column" v-if="!attachment">
-        <div class="ui basic segment">
-          <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3>
-          <p><translate translate-context="Content/*/Paragraph">PNG or JPG. At most 5MB. Will be downscaled to 400x400px.</translate></p>
-          <input class="ui input" ref="attachment" type="file" accept="image/x-png,image/jpeg" @change="submit" />
+    <div class="ui field">
+      <label :for="attachmentId">
+        <slot name="label"></slot>
+      </label>
+      <div class="ui stackable grid row">
+        <div class="three wide column">
+          <img :class="['ui', imageClass, 'image']" v-if="value && value === initialValue" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" />
+          <img :class="['ui', imageClass, 'image']" v-else-if="attachment" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${attachment.uuid}/proxy?next=medium_square_crop`)" />
+          <div :class="['ui', imageClass, 'static', 'large placeholder image']" v-else></div>
+        </div>
+        <div class="eleven wide column">
+          <div class="file-input">
+            <label class="ui basic button" :for="attachmentId">
+              <translate translate-context="*/*/*">Upload New Picture…</translate>
+            </label>
+            <input class="ui hidden input" ref="attachment" type="file" :id="attachmentId" accept="image/x-png,image/jpeg" @change="submit" />
+          </div>
+          <div class="ui very small hidden divider"></div>
+          <p><translate translate-context="Content/*/Paragraph">PNG or JPG. Dimensions should be between 1400x1400px and 3000x3000px. Maximum file size allowed is 5MB.</translate></p>
+          <div class="ui basic tiny button" v-if="value" @click.stop.prevent="remove(value)">
+            <translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
+          </div>
           <div v-if="isLoading" class="ui active inverted dimmer">
             <div class="ui indeterminate text loader">
               <translate translate-context="Content/*/*/Noun">Uploading file…</translate>
@@ -27,7 +35,6 @@
           </div>
         </div>
       </div>
-
     </div>
   </div>
 </template>
@@ -35,12 +42,17 @@
 import axios from 'axios'
 
 export default {
-  props: ['value', 'initialValue'],
+  props: {
+    value: {},
+    imageClass: {default: '', required: false}
+  },
   data () {
     return {
       attachment: null,
       isLoading: false,
       errors: [],
+      initialValue: this.value,
+      attachmentId: Math.random().toString(36).substring(7),
     }
   },
   methods: {
@@ -69,11 +81,11 @@ export default {
           }
         )
     },
-    remove() {
+    remove(uuid) {
       this.isLoading = true
       this.errors = []
       let self = this
-      axios.delete(`attachments/${this.attachment.uuid}/`)
+      axios.delete(`attachments/${uuid}/`)
         .then(
           response => {
             this.isLoading = false
@@ -91,7 +103,7 @@ export default {
     value (v) {
       if (this.attachment && v === this.initialValue) {
         // we had a reset to initial value
-        this.remove()
+        this.remove(this.attachment.uuid)
       }
     }
   }
diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue
index 415b2fc9bcd88e84aa8e87b996c04808eb6a922b..989bb0346cc0fef0548922b629c94ea575b26c68 100644
--- a/front/src/components/common/CopyInput.vue
+++ b/front/src/components/common/CopyInput.vue
@@ -3,7 +3,7 @@
     <p class="message" v-if="copied">
       <translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate>
     </p>
-    <input ref="input" :value="value" type="text">
+    <input ref="input" :value="value" type="text" readonly>
     <button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']">
       <i class="copy icon"></i>
       <translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate>
@@ -43,5 +43,9 @@ export default {
   position: absolute;
   right: 0;
   bottom: -3em;
+  padding: 0.3em;
+  box-shadow: 0px 0px 3px rgba(0, 0, 0, 0.3);
+  background-color: white;
+  z-index: 999;
 }
 </style>
diff --git a/front/src/components/common/DangerousButton.vue b/front/src/components/common/DangerousButton.vue
index 502accf0f9acc8813cfbd4587c199ac8a118b907..969b6c667d456a380cd05a6732fafa27f362b93f 100644
--- a/front/src/components/common/DangerousButton.vue
+++ b/front/src/components/common/DangerousButton.vue
@@ -1,5 +1,5 @@
 <template>
-  <div @click="showModal = true" :class="['ui', color, {disabled: disabled}, 'button']" :disabled="disabled">
+  <div @click="showModal = true" :class="[{disabled: disabled}]" role="button" :disabled="disabled">
     <slot></slot>
 
     <modal class="small" :show.sync="showModal">
@@ -14,7 +14,7 @@
         </div>
       </div>
       <div class="actions">
-        <div class="ui cancel button">
+        <div class="ui basic cancel button">
           <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
         </div>
         <div :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm">
@@ -34,8 +34,7 @@ export default {
   props: {
     action: {type: Function, required: false},
     disabled: {type: Boolean, default: false},
-    color: {type: String, default: 'red'},
-    confirmColor: {type: String, default: null, required: false}
+    confirmColor: {type: String, default: "red", required: false}
   },
   components: {
     Modal
diff --git a/front/src/components/federation/FetchButton.vue b/front/src/components/federation/FetchButton.vue
index 065d72f47b3cf7dbd4a0f3c976d1aa85d550bb0c..c31111ea4ed18881d46b3b78722a9055c9f9388c 100644
--- a/front/src/components/federation/FetchButton.vue
+++ b/front/src/components/federation/FetchButton.vue
@@ -82,7 +82,7 @@
         </div>
       </div>
       <div class="actions">
-        <div role="button" class="ui cancel button">
+        <div role="button" class="ui basic cancel button">
           <translate translate-context="*/*/Button.Label/Verb">Close</translate>
         </div>
         <div role="button" @click="showModal = false; $emit('refresh')" class="ui confirm green button" v-if="fetch && fetch.status === 'finished'">
diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue
index e42f3e826be6cec0707577b85203af0d2e6c71c6..9e43be1c97d8cc8930781ccfd9e00e144a619ce4 100644
--- a/front/src/components/library/AlbumBase.vue
+++ b/front/src/components/library/AlbumBase.vue
@@ -34,7 +34,7 @@
                 </div>
               </div>
               <div class="actions">
-                <div class="ui deny button">
+                <div class="ui basic deny button">
                   <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
                 </div>
               </div>
@@ -73,6 +73,18 @@
                     <i class="edit icon"></i>
                     <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
                   </router-link>
+                  <dangerous-button
+                    :class="['ui', {loading: isLoading}, 'item']"
+                    v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
+                    @confirm="remove()">
+                    <i class="ui trash icon"></i>
+                    <translate translate-context="*/*/*/Verb">Delete…</translate>
+                    <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p>
+                    <div slot="modal-content">
+                      <p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
+                    </div>
+                    <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
+                  </dangerous-button>
                   <div class="divider"></div>
                   <div
                     role="button"
@@ -143,6 +155,7 @@ export default {
     return {
       isLoading: true,
       object: null,
+      artist: null,
       discs: [],
       libraries: [],
       showEmbedModal: false
@@ -160,8 +173,23 @@ export default {
       axios.get(url, {params: {refresh: 'true'}}).then(response => {
         self.object = backend.Album.clean(response.data)
         self.discs = self.object.tracks.reduce(groupByDisc, [])
+        axios.get(`artists/${response.data.artist.id}/`).then(response => {
+          self.artist = response.data
+        })
         self.isLoading = false
       })
+    },
+    remove () {
+      let self = this
+      self.isLoading = true
+      axios.delete(`albums/${this.object.id}`).then((response) => {
+        self.isLoading = false
+        self.$emit('deleted')
+        self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}})
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
     }
   },
   computed: {
diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue
index 10ef24f3b1c844b756c4197d5d34f44c37b2b790..bb5fdfa6447539778fc6f183f99b1d40d929577c 100644
--- a/front/src/components/library/Artists.vue
+++ b/front/src/components/library/Artists.vue
@@ -160,6 +160,7 @@ export default {
         ordering: this.getOrderingAsString(),
         playable: "true",
         tag: this.tags,
+        include_channels: "true",
       }
       logger.default.debug("Fetching artists")
       axios.get(
diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue
index 20435a039e550b1ccf7997d5abe1ce7c16e71444..c255b69c74524ffd4ff5f9d87aff8da2731ba70a 100644
--- a/front/src/components/library/EditCard.vue
+++ b/front/src/components/library/EditCard.vue
@@ -106,7 +106,7 @@
       </button>
       <dangerous-button
         v-if="canDelete"
-        :class="['ui', {loading: isLoading}, 'basic button']"
+        :class="['ui', {loading: isLoading}, 'basic red button']"
         :action="remove">
         <translate translate-context="*/*/*/Verb">Delete</translate>
         <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this suggestion?</translate></p>
diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue
index 17b781e5997a13b0bb60bc8ca567e038f0228caa..ff219a1c21791ec044612d3b3732bc52475b61a0 100644
--- a/front/src/components/library/EditForm.vue
+++ b/front/src/components/library/EditForm.vue
@@ -82,14 +82,15 @@
           <content-form v-model="values[fieldConfig.id].text" :field-id="fieldConfig.id" :rows="3"></content-form>
         </template>
         <template v-else-if="fieldConfig.type === 'attachment'">
-          <label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
           <attachment-input
             v-model="values[fieldConfig.id]"
             :initial-value="initialValues[fieldConfig.id]"
             :required="fieldConfig.required"
             :name="fieldConfig.id"
             :id="fieldConfig.id"
-            @delete="values[fieldConfig.id] = initialValues[fieldConfig.id]"></attachment-input>
+            @delete="values[fieldConfig.id] = initialValues[fieldConfig.id]">
+            <span slot="label">{{ fieldConfig.label }}</span>
+          </attachment-input>
 
         </template>
         <template v-else-if="fieldConfig.type === 'tags'">
diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue
index 9e86600b37496fb9cfe28c6ff0f300ad8eedf599..7aed6ade5b8190aff63d6fe62e8222ee7725d49b 100644
--- a/front/src/components/library/FileUpload.vue
+++ b/front/src/components/library/FileUpload.vue
@@ -180,7 +180,6 @@ export default {
       currentTab: "summary",
       uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
       importReference,
-      supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
       isLoadingQuota: false,
       quotaStatus: null,
       uploads: {
@@ -283,6 +282,9 @@ export default {
     }
   },
   computed: {
+    supportedExtensions () {
+      return this.$store.state.ui.supportedExtensions
+    },
     labels() {
       let denied = this.$pgettext('Content/Library/Help text',
         "Upload denied, ensure the file is not too big and that you have not reached your quota"
diff --git a/front/src/components/library/FileUploadWidget.vue b/front/src/components/library/FileUploadWidget.vue
index 93bead3e7a8bb6b7a263df698761e8d4853a1b72..95c553d03961413a80775bce871e1d155279b4d1 100644
--- a/front/src/components/library/FileUploadWidget.vue
+++ b/front/src/components/library/FileUploadWidget.vue
@@ -6,10 +6,19 @@ export default {
   methods: {
     uploadHtml5 (file) {
       let form = new window.FormData()
+      let filename = file.file.filename || file.name
       let value
-      for (let key in file.data) {
-        value = file.data[key]
-        if (value && typeof value === 'object' && typeof value.toString !== 'function') {
+      let data = {...file.data}
+      if (data.import_metadata) {
+        data.import_metadata = {...(data.import_metadata || {})}
+        if (data.channel && !data.import_metadata.title) {
+          data.import_metadata.title = filename.replace(/\.[^/.]+$/, "")
+        }
+        data.import_metadata = JSON.stringify(data.import_metadata)
+      }
+      for (let key in data) {
+        value = data[key]
+        if (value &&   typeof value === 'object' && typeof value.toString !== 'function') {
           if (value instanceof File) {
             form.append(key, value, value.name)
           } else {
@@ -19,7 +28,6 @@ export default {
           form.append(key, value)
         }
       }
-      let filename = file.file.filename || file.name
       form.append('source', `upload://${filename}`)
       form.append(this.name, file.file, filename)
       let xhr = new XMLHttpRequest()
diff --git a/front/src/components/library/TagsSelector.vue b/front/src/components/library/TagsSelector.vue
index 6a1fe06e825c8c2309c0638df7dc6a40878a8c9d..971f4e5f14463221bc15289097041699d388e398 100644
--- a/front/src/components/library/TagsSelector.vue
+++ b/front/src/components/library/TagsSelector.vue
@@ -29,14 +29,30 @@ export default {
         return value
       }
       let settings = {
+        keys : {
+          delimiter  : 32,
+        },
+        forceSelection: false,
         saveRemoteData: false,
         filterRemoteData: true,
+        preserveHTML : false,
         apiSettings: {
           url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'),
           beforeXHR: function (xhrObject) {
             xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
             return xhrObject
           },
+          onResponse(response) {
+            let currentSearch = $(self.$refs.dropdown).dropdown('get query')
+            response = {
+              results: [],
+              ...response,
+            }
+            if (currentSearch) {
+              response.results = [{name: currentSearch}, ...response.results]
+            }
+            return response
+          }
         },
         fields: {
           remoteValues: 'results',
@@ -74,4 +90,3 @@ export default {
 }
 
 </style>
-
diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue
index ea20dc44ef67fee91ade50328ee44b0d6d65affc..747446f0075971ed193ff5772b3f2afc71400b38 100644
--- a/front/src/components/library/TrackBase.vue
+++ b/front/src/components/library/TrackBase.vue
@@ -1,6 +1,6 @@
 <template>
   <main>
-    <div v-if="isLoadingTrack" class="ui vertical segment" v-title="labels.title">
+    <div v-if="isLoading" class="ui vertical segment" v-title="labels.title">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
     <template v-if="track">
@@ -50,7 +50,7 @@
                 </div>
               </div>
               <div class="actions">
-                <div class="ui deny button">
+                <div class="ui basic deny button">
                   <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
                 </div>
               </div>
@@ -89,6 +89,18 @@
                     <i class="edit icon"></i>
                     <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
                   </router-link>
+                  <dangerous-button
+                    :class="['ui', {loading: isLoading}, 'item']"
+                    v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
+                    @confirm="remove()">
+                    <i class="ui trash icon"></i>
+                    <translate translate-context="*/*/*/Verb">Delete…</translate>
+                    <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this track?</translate></p>
+                    <div slot="modal-content">
+                      <p><translate translate-context="Content/Moderation/Paragraph">The track will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
+                    </div>
+                    <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
+                  </dangerous-button>
                   <div class="divider"></div>
                   <div
                     role="button"
@@ -151,8 +163,9 @@ export default {
   data() {
     return {
       time,
-      isLoadingTrack: true,
+      isLoading: true,
       track: null,
+      artist: null,
       showEmbedModal: false,
       libraries: []
     }
@@ -163,14 +176,29 @@ export default {
   methods: {
     fetchData() {
       var self = this
-      this.isLoadingTrack = true
+      this.isLoading = true
       let url = FETCH_URL + this.id + "/"
       logger.default.debug('Fetching track "' + this.id + '"')
       axios.get(url, {params: {refresh: 'true'}}).then(response => {
         self.track = response.data
-        self.isLoadingTrack = false
+        axios.get(`artists/${response.data.artist.id}/`).then(response => {
+          self.artist = response.data
+        })
+        self.isLoading = false
       })
     },
+    remove () {
+      let self = this
+      self.isLoading = true
+      axios.delete(`tracks/${this.track.id}`).then((response) => {
+        self.isLoading = false
+        self.$emit('deleted')
+        self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}})
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
   },
   computed: {
     publicLibraries () {
@@ -224,7 +252,9 @@ export default {
       return u
     },
     cover() {
-      return null
+      if (this.track.cover) {
+        return this.track.cover
+      }
     },
     albumUrl () {
       let route = this.$router.resolve({name: 'library.albums.detail', params: {id: this.track.album.id }})
@@ -235,12 +265,12 @@ export default {
       return route.href
     },
     headerStyle() {
-      if (!this.cover) {
+      if (!this.cover || !this.cover.original) {
         return ""
       }
       return (
         "background-image: url(" +
-        this.$store.getters["instance/absoluteUrl"](this.cover) +
+        this.$store.getters["instance/absoluteUrl"](this.cover.original) +
         ")"
       )
     },
diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue
index b0eabbd6e6054f5be4a6afbee00e16fb2d423a26..023f41f34759147a2915783f8181b8fa343aed3e 100644
--- a/front/src/components/library/radios/Filter.vue
+++ b/front/src/components/library/radios/Filter.vue
@@ -50,7 +50,7 @@
           </div>
         </div>
         <div class="actions">
-          <div class="ui black deny button">
+          <div class="ui basic black deny button">
             <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
           </div>
         </div>
diff --git a/front/src/components/manage/moderation/InstancePolicyForm.vue b/front/src/components/manage/moderation/InstancePolicyForm.vue
index ebb946d5a661804a6129b02021622b124e7854be..f6b554d00d45999aca4e1be9d9cac1640a0e93a2 100644
--- a/front/src/components/manage/moderation/InstancePolicyForm.vue
+++ b/front/src/components/manage/moderation/InstancePolicyForm.vue
@@ -58,7 +58,7 @@
       <translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-if="object" key="1">Update</translate>
       <translate translate-context="Content/Moderation/Card.Button.Label/Verb" v-else key="2">Create</translate>
     </button>
-    <dangerous-button v-if="object" class="right floated basic button" color='red' @confirm="remove">
+    <dangerous-button v-if="object" class="ui right floated basic red button" @confirm="remove">
       <translate translate-context="*/*/*/Verb">Delete</translate>
       <p slot="modal-header">
         <translate translate-context="Popup/Moderation/Title">Delete this moderation rule?</translate>
diff --git a/front/src/components/manage/moderation/NotesThread.vue b/front/src/components/manage/moderation/NotesThread.vue
index 705354ec8e7c5f90b8955453f6e80c8f0dbf4395..393e4fcbdca89a3e3c41aaedf41533f41c8b0bf5 100644
--- a/front/src/components/manage/moderation/NotesThread.vue
+++ b/front/src/components/manage/moderation/NotesThread.vue
@@ -18,8 +18,7 @@
         </div>
         <div class="meta">
           <dangerous-button
-            :class="['ui', {loading: isLoading}, 'basic borderless mini button']"
-            color="grey"
+            :class="['ui', {loading: isLoading}, 'basic borderless mini grey button']"
             @confirm="remove(note)">
             <i class="trash icon"></i>
             <translate translate-context="*/*/*/Verb">Delete</translate>
diff --git a/front/src/components/manage/moderation/ReportCard.vue b/front/src/components/manage/moderation/ReportCard.vue
index 65fb3773cc4cb665672301d969ed972571943190..73cc142db7a37aa341eba048756eeb02bef9f008 100644
--- a/front/src/components/manage/moderation/ReportCard.vue
+++ b/front/src/components/manage/moderation/ReportCard.vue
@@ -229,7 +229,6 @@
               <dangerous-button
                 v-if="action.dangerous && action.show(obj)"
                 :class="['ui', {loading: isLoading}, 'button']"
-                color=""
                 :action="action.handler">
                 <i :class="[action.iconColor, action.icon, 'icon']"></i>&nbsp;
                 {{ action.label }}
diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue
index 8b3d3a2a564c4aed2fc346e5993a11001ba6964d..47eae2b66b33956728631094a57ff44fe0eaca80 100644
--- a/front/src/components/mixins/Translations.vue
+++ b/front/src/components/mixins/Translations.vue
@@ -25,6 +25,10 @@ export default {
                 label: this.$pgettext('Content/Library/*', 'Skipped'),
                 help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries'),
               },
+              draft: {
+                label: this.$pgettext('Content/Library/*/Short', 'Draft'),
+                help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been scheduled for processing yet'),
+              },
               pending: {
                 label: this.$pgettext('Content/Library/*/Short', 'Pending'),
                 help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet'),
diff --git a/front/src/components/moderation/FilterModal.vue b/front/src/components/moderation/FilterModal.vue
index 51e9eb70e9bfb2ea5948967f95f2a03429ba5c8c..bad0b2a23655b2c016fda6b4e9b1375cadd57bc2 100644
--- a/front/src/components/moderation/FilterModal.vue
+++ b/front/src/components/moderation/FilterModal.vue
@@ -37,7 +37,7 @@
       </div>
     </div>
     <div class="actions">
-      <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
+      <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
       <div :class="['ui', 'green', {loading: isLoading}, 'button']" @click="hide"><translate translate-context="Popup/*/Button.Label">Hide content</translate></div>
     </div>
   </modal>
diff --git a/front/src/components/moderation/ReportModal.vue b/front/src/components/moderation/ReportModal.vue
index 6fa1ba8e024cada6d84437227a07ee2e89140bb8..0f58712cd2a1d912b57f4874a54e558a4933636e 100644
--- a/front/src/components/moderation/ReportModal.vue
+++ b/front/src/components/moderation/ReportModal.vue
@@ -57,7 +57,7 @@
       </div>
     </div>
     <div class="actions">
-      <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
+      <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
       <button
         v-if="canSubmit"
         :class="['ui', 'green', {loading: isLoading}, 'button']"
diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue
index 59f5ea809a6424e830f9044d77dd1ea82aa8064b..bb732301f66689fa8a57db40b0d819e56db55908 100644
--- a/front/src/components/playlists/Editor.vue
+++ b/front/src/components/playlists/Editor.vue
@@ -47,7 +47,7 @@
           </translate>
         </div>
 
-      <dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
+      <dangerous-button :disabled="plts.length === 0" class="ui labeled right floated yellow icon button" :action="clearPlaylist">
         <i class="eraser icon"></i> <translate translate-context="*/Playlist/Button.Label/Verb">Clear playlist</translate>
         <p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title"  :translate-params="{playlist: playlist.name}">
           Do you want to clear the playlist "%{ playlist }"?
diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue
index 7ada395b6011b016b0c7f3882ab807631bf3e24a..07a75902741e71b8dc017dc969817c33149bbf3e 100644
--- a/front/src/components/playlists/PlaylistModal.vue
+++ b/front/src/components/playlists/PlaylistModal.vue
@@ -25,7 +25,7 @@
             :translate-params="{track: track.title, playlist: duplicateTrackAddInfo.playlist_name}"><strong>%{ track }</strong> is already in <strong>%{ playlist }</strong>.</p>
           <button
             @click="duplicateTrackAddConfirm(false)"
-            class="ui small cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+            class="ui small basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
           </button>
           <button
             class="ui small green button"
@@ -101,7 +101,7 @@
       </div>
     </div>
     <div class="actions">
-      <div class="ui cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
+      <div class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></div>
     </div>
   </modal>
 </template>
diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue
index 828e842ab8a71a49543a6ac78fa569a73c54448d..599ef02229e61be5f35ec03d2832a0bbdc6c1cf2 100644
--- a/front/src/components/semantic/Modal.vue
+++ b/front/src/components/semantic/Modal.vue
@@ -1,6 +1,6 @@
 <template>
-  <div :class="['ui', {'active': show}, 'modal']">
-    <i class="close icon"></i>
+  <div :class="['ui', {'active': show}, {'overlay fullscreen': ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']">
+    <i class="close inside icon"></i>
     <slot v-if="show">
 
     </slot>
diff --git a/front/src/lodash.js b/front/src/lodash.js
index f633e9a94f503d836e0f2d36cc64105582a95889..ce045f5a1edefc8c2df20cf6e2d6fa46db0df3ef 100644
--- a/front/src/lodash.js
+++ b/front/src/lodash.js
@@ -15,4 +15,5 @@ export default {
   reverse: require('lodash/reverse'),
   isEqual: require('lodash/isEqual'),
   sum: require('lodash/sum'),
+  startCase: require('lodash/startCase'),
 }
diff --git a/front/src/main.js b/front/src/main.js
index cea2d095ff2d1748612eed87753fce73d6aaa99f..13fb9f1966ac9017d504ccff2285373b866445dc 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -19,6 +19,7 @@ import { sync } from 'vuex-router-sync'
 import locales from '@/locales'
 
 import filters from '@/filters' // eslint-disable-line
+import {parseAPIErrors} from '@/utils'
 import globals from '@/components/globals' // eslint-disable-line
 import './registerServiceWorker'
 
@@ -67,6 +68,7 @@ Vue.directive('title', function (el, binding) {
 Vue.directive('dropdown', function (el, binding) {
   jQuery(el).dropdown({
     selectOnKeydown: false,
+    ...(binding.value || {})
   })
 })
 axios.interceptors.request.use(function (config) {
@@ -127,15 +129,8 @@ axios.interceptors.response.use(function (response) {
       error.backendErrors.push(error.response.data.detail)
     } else {
       error.rawPayload = error.response.data
-      for (var field in error.response.data) {
-        // some views (e.g. v1/playlists/{id}/add) have deeper nested data (e.g. data[field]
-        // is another object), so don't try to unpack non-array fields
-        if (error.response.data.hasOwnProperty(field) && error.response.data[field].forEach) {
-          error.response.data[field].forEach(e => {
-            error.backendErrors.push(e)
-          })
-        }
-      }
+      let parsedErrors = parseAPIErrors(error.response.data)
+      error.backendErrors = [...error.backendErrors, ...parsedErrors]
     }
   }
   if (error.backendErrors.length === 0) {
@@ -157,6 +152,19 @@ store.dispatch('instance/fetchFrontSettings').finally(() => {
     components: { App },
     created () {
       APP = this
+      window.addEventListener('resize', this.handleResize)
+      this.handleResize();
+    },
+    destroyed() {
+      window.removeEventListener('resize', this.handleResize)
+    },
+    methods: {
+      handleResize() {
+        this.$store.commit('ui/window', {
+          width: window.innerWidth,
+          height: window.innerHeight,
+        })
+      }
     },
   })
 
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 9a573b2b759c347acc27522d7c39546912131002..233ad1f443fe6586129679a4f465ff1c3b502572 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -9,6 +9,9 @@ export default new Router({
   linkActiveClass: "active",
   base: process.env.VUE_APP_ROUTER_BASE_URL || "/",
   scrollBehavior(to, from, savedPosition) {
+    if (to.meta.preserveScrollPosition) {
+      return savedPosition
+    }
     return new Promise(resolve => {
       setTimeout(() => {
         if (to.hash) {
diff --git a/front/src/store/channels.js b/front/src/store/channels.js
index 0f2c8b23c0f9898fa900320201e2079164e6fa31..d6c14f8357b54acf937fff8ebf6fd167c5b6ac2e 100644
--- a/front/src/store/channels.js
+++ b/front/src/store/channels.js
@@ -5,7 +5,12 @@ export default {
   namespaced: true,
   state: {
     subscriptions: [],
-    count: 0
+    count: 0,
+    showUploadModal: false,
+    latestPublication: null,
+    uploadModalConfig: {
+      channel: null,
+    }
   },
   mutations: {
     subscriptions: (state, {uuid, value}) => {
@@ -24,6 +29,22 @@ export default {
     reset (state) {
       state.subscriptions = []
       state.count = 0
+    },
+    showUploadModal (state, value) {
+      state.showUploadModal = value.show
+      if (value.config) {
+        state.uploadModalConfig = {
+          ...value.config
+        }
+      }
+    },
+    publish (state, {uploads, channel}) {
+      state.latestPublication = {
+        date: new Date(),
+        uploads,
+        channel,
+      }
+      state.showUploadModal = false
     }
   },
   getters: {
diff --git a/front/src/store/ui.js b/front/src/store/ui.js
index 195458d2fe74ad692413890d22e40fce972c6b4f..f6346d6f858bfd74a7fc93cc6658c5bb5c8f228f 100644
--- a/front/src/store/ui.js
+++ b/front/src/store/ui.js
@@ -11,8 +11,13 @@ export default {
     lastDate: new Date(),
     maxMessages: 100,
     messageDisplayDuration: 10000,
+    supportedExtensions: ["flac", "ogg", "mp3", "opus", "aac", "m4a"],
     messages: [],
     theme: 'light',
+    window: {
+      height: 0,
+      width: 0,
+    },
     notifications: {
       inbox: 0,
       pendingReviewEdits: 0,
@@ -125,6 +130,33 @@ export default {
         count += 1
       }
       return count
+    },
+
+    windowSize: (state, getters) => {
+      // IMPORTANT: if you modify these breakpoints, also modify the values in
+      // style/vendor/_media.scss
+      let width = state.window.width
+      let breakpoints = [
+        {name: 'widedesktop', width: 1200},
+        {name: 'desktop', width: 1024},
+        {name: 'tablet', width: 768},
+        {name: 'phone', width: 320},
+      ]
+      for (let index = 0; index < breakpoints.length; index++) {
+        const element = breakpoints[index];
+        if (width >= element.width) {
+          return element.name
+        }
+      }
+      return 'phone'
+
+    },
+    layoutVersion: (state, getters) => {
+      if (['tablet', 'phone'].indexOf(getters.windowSize) > -1) {
+        return 'small'
+      } else {
+        return 'large'
+      }
     }
   },
   mutations: {
@@ -193,6 +225,9 @@ export default {
 
     serviceWorker: (state, value) => {
       state.serviceWorker = {...state.serviceWorker, ...value}
+    },
+    window: (state, value) => {
+      state.window = value
     }
   },
   actions: {
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index 365e1da22a5d229a143a05faf4db639b82f9825c..125293b994df3878f34f5a7453c9cf1e9d3030b4 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -112,7 +112,6 @@ html {
     .toast-container {
       bottom: $bottom-player-height + 1rem;
     }
-
   }
 }
 
@@ -141,6 +140,7 @@ html {
 #app {
   > .main.pusher,
   > .footer {
+    position: relative;
     @include media(">desktop") {
       margin-left: $desktop-sidebar-width !important;
     }
@@ -263,6 +263,9 @@ a {
 .segment.hidden {
   display: none;
 }
+.hidden:not(.divider) {
+  display: none !important;
+}
 
 .nomargin {
   margin: 0 !important;
@@ -291,6 +294,9 @@ button.reset {
   text-align: inherit;
 }
 
+.text.align.left {
+  text-align: left;
+}
 .ui.table > caption {
   font-weight: bold;
   padding: 0.5em;
@@ -422,6 +428,11 @@ input + .help {
     display: none !important;
   }
 }
+.mobile-only {
+  @include media(">tablet") {
+    display: none !important;
+  }
+}
 :not(.menu) > {
   a, .link {
     &:not(.button):not(.list) {
@@ -433,19 +444,36 @@ input + .help {
 }
 .ui.cards.app-cards {
   $card-width: 14em;
-  $card-hight: 22em;
+  $card-height: 22em;
+  $small-card-width: 11em;
+  $small-card-height: 19em;
   .app-card {
     display: flex;
-    width: $card-width;
-    height: $card-hight;
+    width: $small-card-width;
+    height: $small-card-height;
+    font-size: 95%;
+    @include media(">tablet") {
+      font-size: 100%;
+      width: $card-width;
+      height: $card-height;
+    }
     .content:not(.extra) {
-      padding: 0.5em 1em 0;
+      padding: 0.25em 0.5em 0;
+      @include media(">tablet") {
+        padding: 0.5em 1em 0;
+      }
     }
     .content.extra {
-      padding: 0.5em 1em;
+      padding: 0.25em 0.5em;
+      @include media(">tablet") {
+        padding: 0.5em 1em;
+      }
     }
     .head-image {
-      height: $card-width;
+      height: $small-card-width;
+      @include media(">tablet") {
+        height: $card-width;
+      }
       background-size: cover !important;
       background-position: center !important;
       display: flex !important;
@@ -457,9 +485,14 @@ input + .help {
       &.circular {
         overflow: visible;
         border-radius: 50% !important;
-        height: $card-width - 1em;
-        width: $card-width - 1em;
-        margin: 0.5em;
+        width: $small-card-width - 0.5em;
+        height: $small-card-width - 0.5em;
+        margin: 0.25em;
+        @include media(">tablet") {
+          width: $card-width - 1em;
+          height: $card-width - 1em;
+          margin: 0.5em;
+        }
 
       }
       &.padded {
@@ -543,7 +576,8 @@ input + .help {
   }
 }
 .channel-image {
-  border: 1px solid rgba(0, 0, 0, 0.5);
+  border: 1px solid rgba(0, 0, 0, 0.15);
+  background-color: white;
   border-radius: 0.3em;
   &.large {
     width: 8em !important;
@@ -560,5 +594,70 @@ input + .help {
     width: 100%;
   }
 }
+.placeholder.image {
+  background-color: rgba(0,0,0,.08);
+  width: 3em;
+  height: 3em;
+  &.large {
+    width: 8em;
+    height: 8em;
+  }
+  max-width: 100%;
+  display: block;
+  &.circular {
+    border-radius: 50%;
+  }
+  &.static {
+    animation: none;
+  }
+}
+.channel-type.field .radio {
+  display: block;
+  padding: 1.5em;
+  &.selected {
+    background-color: rgba(0, 0, 0, 0.05);
+  }
+}
+.header.with-actions {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  .actions {
+    font-weight: normal;
+    font-size: 0.6em;
+  }
+
+}
+.file-uploads.channels.ui.button {
+  display: block;
+  padding: 2em 1em;
+  width: 100%;
+  box-shadow: none;
+  border-style: dashed !important;
+  border: 2px solid rgba(50, 50, 50, 0.5);
+  font-size: 1.2em;
+  padding: 0;
+  > div:not(.divider) {
+    padding: 1em;
+  }
+}
+
+.channel-file {
+  display: flex;
+  align-items: top;
+  margin-bottom: 1em;
+  > :first-child {
+    width: 3em;
+  }
+  .header {
+    margin: 0 1em;
+    .sub.header {
+      margin-top: 0.5em;
+    }
+  }
+}
+.modal > .header {
+  text-align: center;
+}
 @import "./themes/_light.scss";
 @import "./themes/_dark.scss";
diff --git a/front/src/style/vendor/_media.scss b/front/src/style/vendor/_media.scss
index 8d24baa71780a4c9ca6c8fffeb9e9ab1ae6e94d7..75305323cb2aa37551b5fe29c14b2856f5495499 100644
--- a/front/src/style/vendor/_media.scss
+++ b/front/src/style/vendor/_media.scss
@@ -31,6 +31,9 @@
 /// @example scss - Creates a single breakpoint with the label `phone`
 ///  $breakpoints: ('phone': 320px);
 ///
+
+// IMPORTANT: if you modify these breakpoints, also modify the values in
+// store/ui.js#windowSize
 $breakpoints: (
   'phone': 320px,
   'tablet': 768px,
diff --git a/front/src/utils.js b/front/src/utils.js
index eee36f8e68df0aea62b13e2d0e598dfe1c911b3f..ad763fe004da937e3ab614ad9622dd1fda3af5d1 100644
--- a/front/src/utils.js
+++ b/front/src/utils.js
@@ -6,3 +6,30 @@ export function setUpdate(obj, statuses, value) {
     statuses[k] = value
   })
 }
+
+export function parseAPIErrors(responseData, parentField) {
+  let errors = []
+  for (var field in responseData) {
+    if (responseData.hasOwnProperty(field)) {
+      let value = responseData[field]
+      let fieldName = lodash.startCase(field.replace('_', ' '))
+      if (parentField) {
+        fieldName = `${parentField} - ${fieldName}`
+      }
+      if (value.forEach) {
+        value.forEach(e => {
+          if (e.toLocaleLowerCase().includes('this field ')) {
+            errors.push(`${fieldName}: ${e}`)
+          } else {
+            errors.push(e)
+          }
+        })
+      } else if (typeof value === 'object') {
+        // nested errors
+        let nestedErrors = parseAPIErrors(value, fieldName)
+        errors = [...errors, ...nestedErrors]
+      }
+    }
+  }
+  return errors
+}
diff --git a/front/src/views/admin/library/AlbumDetail.vue b/front/src/views/admin/library/AlbumDetail.vue
index 6ca2acf8812123955462dfeb7f567406c0009daf..9cce0c8f4f5391ce4e70ead84eeb4f0a15c255f8 100644
--- a/front/src/views/admin/library/AlbumDetail.vue
+++ b/front/src/views/admin/library/AlbumDetail.vue
@@ -74,7 +74,7 @@
                 </div>
                 <div class="ui buttons">
                   <dangerous-button
-                    :class="['ui', {loading: isLoading}, 'basic button']"
+                    :class="['ui', {loading: isLoading}, 'basic red button']"
                     :action="remove">
                     <translate translate-context="*/*/*/Verb">Delete</translate>
                     <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p>
diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue
index 3ee193e3547db5e699869970deca3de444d500fc..5544445bb19767c8cd35fc907eac1ab3b8e3bceb 100644
--- a/front/src/views/admin/library/ArtistDetail.vue
+++ b/front/src/views/admin/library/ArtistDetail.vue
@@ -73,7 +73,7 @@
                 </div>
                 <div class="ui buttons">
                   <dangerous-button
-                    :class="['ui', {loading: isLoading}, 'basic button']"
+                    :class="['ui', {loading: isLoading}, 'basic red button']"
                     :action="remove">
                     <translate translate-context="*/*/*/Verb">Delete</translate>
                     <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this artist?</translate></p>
diff --git a/front/src/views/admin/library/LibraryDetail.vue b/front/src/views/admin/library/LibraryDetail.vue
index 2a7b74767029294c8578c3c9276323ce462e6f90..412795d71e3a1fa720feb21a176bf58a602bbb90 100644
--- a/front/src/views/admin/library/LibraryDetail.vue
+++ b/front/src/views/admin/library/LibraryDetail.vue
@@ -54,7 +54,7 @@
                 </div>
                 <div class="ui buttons">
                   <dangerous-button
-                    :class="['ui', {loading: isLoading}, 'basic button']"
+                    :class="['ui', {loading: isLoading}, 'basic red button']"
                     :action="remove">
                     <translate translate-context="*/*/*/Verb">Delete</translate>
                     <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this library?</translate></p>
diff --git a/front/src/views/admin/library/TagDetail.vue b/front/src/views/admin/library/TagDetail.vue
index c434f2805f45f278a5e0be8ec5aa9eb74ff4416c..5d5dac2351ae47bfc6ebce74b64697340e408e1d 100644
--- a/front/src/views/admin/library/TagDetail.vue
+++ b/front/src/views/admin/library/TagDetail.vue
@@ -37,7 +37,7 @@
                 </div>
                 <div class="ui buttons">
                   <dangerous-button
-                    :class="['ui', {loading: isLoading}, 'basic button']"
+                    :class="['ui', {loading: isLoading}, 'basic red button']"
                     :action="remove">
                     <translate translate-context="*/*/*/Verb">Delete</translate>
                     <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this tag?</translate></p>
diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue
index f01457b1246da444c79a1b16f065ad50ff488d28..dab02cc7bdb96a18a427782203e82a5a2ad30e73 100644
--- a/front/src/views/admin/library/TrackDetail.vue
+++ b/front/src/views/admin/library/TrackDetail.vue
@@ -74,7 +74,7 @@
                 </div>
                 <div class="ui buttons">
                   <dangerous-button
-                    :class="['ui', {loading: isLoading}, 'basic button']"
+                    :class="['ui', {loading: isLoading}, 'basic red button']"
                     :action="remove">
                     <translate translate-context="*/*/*/Verb">Delete</translate>
                     <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p>
diff --git a/front/src/views/admin/library/UploadDetail.vue b/front/src/views/admin/library/UploadDetail.vue
index cf2ddc0a98670ff69afa89cd895222b7d2514a1b..3cd32ed41f444bb1abfafebe9ca8def477d29e18 100644
--- a/front/src/views/admin/library/UploadDetail.vue
+++ b/front/src/views/admin/library/UploadDetail.vue
@@ -61,7 +61,7 @@
                 </div>
                 <div class="ui buttons">
                   <dangerous-button
-                    :class="['ui', {loading: isLoading}, 'basic button']"
+                    :class="['ui', {loading: isLoading}, 'basic red button']"
                     :action="remove">
                     <translate translate-context="*/*/*/Verb">Delete</translate>
                     <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this upload?</translate></p>
diff --git a/front/src/views/auth/ProfileActivity.vue b/front/src/views/auth/ProfileActivity.vue
index 1db7ea2a0fa807814110b46f1e18906110d208e8..50ef0e9259b27c161bd693cec7ebde5a6537e9dc 100644
--- a/front/src/views/auth/ProfileActivity.vue
+++ b/front/src/views/auth/ProfileActivity.vue
@@ -1,19 +1,21 @@
 <template>
-  <section class="ui stackable three column grid">
-    <div class="column">
+  <section>
+    <div>
       <h2 class="ui header">
         <translate translate-context="Content/Home/Title">Recently listened</translate>
       </h2>
       <track-widget :url="'history/listenings/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}">
       </track-widget>
     </div>
-    <div class="column">
+    <div class="ui hidden divider"></div>
+    <div>
       <h2 class="ui header">
         <translate translate-context="Content/Home/Title">Recently favorited</translate>
       </h2>
       <track-widget :url="'favorites/tracks/'" :filters="{scope: `actor:${object.full_username}`, ordering: '-creation_date'}"></track-widget>
     </div>
-    <div class="column">
+    <div class="ui hidden divider"></div>
+    <div>
       <h2 class="ui header">
         <translate translate-context="*/*/*">Playlists</translate>
       </h2>
diff --git a/front/src/views/auth/ProfileBase.vue b/front/src/views/auth/ProfileBase.vue
index 927446649304225179b835f91786bb926aa6eb75..91bebf36b23b77e162910abcc1b67325533db6ac 100644
--- a/front/src/views/auth/ProfileBase.vue
+++ b/front/src/views/auth/ProfileBase.vue
@@ -3,60 +3,77 @@
     <div v-if="isLoading" class="ui vertical segment">
       <div class="ui centered active inline loader"></div>
     </div>
-    <template v-if="object">
-      <div class="ui dropdown icon small basic right floated button" ref="dropdown" v-dropdown style="right: 1em; top: 1em; z-index: 5">
-        <i class="ellipsis vertical icon"></i>
-        <div class="menu">
-          <div
-            role="button"
-            class="basic item"
-            v-for="obj in getReportableObjs({account: object})"
-            :key="obj.target.type + obj.target.id"
-            @click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
-            <i class="share icon" /> {{ obj.label }}
-          </div>
+    <div class="ui head vertical stripe segment container">
+      <div class="ui stackable grid" v-if="object">
+        <div class="ui five wide column">
+          <div class="ui pointing dropdown icon small basic right floated button" ref="dropdown" v-dropdown="{direction: 'downward'}" style="position: absolute; right: 1em; top: 1em; z-index: 5">
+            <i class="ellipsis vertical icon"></i>
+            <div class="menu">
+              <div
+                role="button"
+                class="basic item"
+                v-for="obj in getReportableObjs({account: object})"
+                :key="obj.target.type + obj.target.id"
+                @click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
+                <i class="share icon" /> {{ obj.label }}
+              </div>
 
-          <div class="divider"></div>
-          <router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
-            <i class="wrench icon"></i>
-            <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
-          </router-link>
-        </div>
-      </div>
-      <div class="ui head vertical stripe segment">
-        <h1 class="ui center aligned icon header">
-          <i v-if="!object.icon" class="circular inverted user green icon"></i>
-          <img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
-          <div class="ellispsis content">
-            <div class="ui very small hidden divider"></div>
-            <span :title="displayName">{{ displayName }}</span>
-            <div class="ui very small hidden divider"></div>
-            <span class="ui grey tiny text" :title="object.full_username">{{ object.full_username }}</span>
+              <div class="divider"></div>
+              <router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
+                <i class="wrench icon"></i>
+                <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
+              </router-link>
+            </div>
           </div>
-          <template  v-if="object.full_username === $store.state.auth.fullUsername">
-            <div class="ui very small hidden divider"></div>
-            <div class="ui basic green label">
-              <translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
+          <h1 class="ui center aligned icon header">
+            <i v-if="!object.icon" class="circular inverted user green icon"></i>
+            <img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
+            <div class="ellispsis content">
+              <div class="ui very small hidden divider"></div>
+              <span :title="displayName">{{ displayName }}</span>
+              <div class="ui very small hidden divider"></div>
+              <div class="sub header ellipsis" :title="object.full_username">
+                {{ object.full_username }}
+              </div>
             </div>
-          </template>
-        </h1>
-        <div class="ui container">
-          <div class="ui secondary pointing center aligned menu">
-            <router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}">
-              <translate translate-context="Content/Profile/Link">Overview</translate>
-            </router-link>
-            <router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}">
-              <translate translate-context="Content/Profile/*">Activity</translate>
-            </router-link>
+            <template  v-if="object.full_username === $store.state.auth.fullUsername">
+              <div class="ui very small hidden divider"></div>
+              <div class="ui basic green label">
+                <translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
+              </div>
+            </template>
+          </h1>
+          <div class="ui small hidden divider"></div>
+          <div v-if="$store.getters['ui/layoutVersion'] === 'large'">
+            <rendered-description
+              @updated="$emit('updated', $event)"
+              :content="object.summary"
+              :field-name="'summary'"
+              :update-url="`users/users/${$store.state.auth.username}/`"
+              :can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
           </div>
-          <div class="ui hidden divider"></div>
-          <keep-alive>
-            <router-view @updated="fetch" :object="object"></router-view>
-          </keep-alive>
+        </div>
+        <div class="ui eleven wide column">
+          <div class="ui head vertical stripe segment">
+            <div class="ui container">
+              <div class="ui secondary pointing center aligned menu">
+                <router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}">
+                  <translate translate-context="Content/Profile/Link">Overview</translate>
+                </router-link>
+                <router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}">
+                  <translate translate-context="Content/Profile/*">Activity</translate>
+                </router-link>
+              </div>
+              <div class="ui hidden divider"></div>
+              <keep-alive>
+                <router-view @updated="fetch" :object="object"></router-view>
+              </keep-alive>
 
+            </div>
+          </div>
         </div>
       </div>
-    </template>
+    </div>
   </main>
 </template>
 
@@ -81,6 +98,10 @@ export default {
   created() {
     this.fetch()
   },
+  beforeRouteUpdate (to, from, next) {
+    to.meta.preserveScrollPosition = true
+    next()
+  },
   methods: {
     fetch () {
       let self = this
diff --git a/front/src/views/auth/ProfileOverview.vue b/front/src/views/auth/ProfileOverview.vue
index cf65e42b9fd230d68a99f0b6e09b70ecc5db5d4f..5c524a107abe206cceab53fcd11903499a94297b 100644
--- a/front/src/views/auth/ProfileOverview.vue
+++ b/front/src/views/auth/ProfileOverview.vue
@@ -1,17 +1,23 @@
 <template>
-  <section class="ui stackable grid">
-    <div class="six wide column">
+  <section>
+    <div v-if="$store.getters['ui/layoutVersion'] === 'small'">
       <rendered-description
         @updated="$emit('updated', $event)"
         :content="object.summary"
         :field-name="'summary'"
         :update-url="`users/users/${$store.state.auth.username}/`"
         :can-update="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername"></rendered-description>
-
+      <div class="ui hidden divider"></div>
     </div>
-    <div class="ten wide column">
-      <h2 class="ui header">
+    <div>
+      <h2 class="ui with-actions header">
         <translate translate-context="*/*/*">Channels</translate>
+        <div class="actions" v-if="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername">
+          <a @click.stop.prevent="showCreateModal = true">
+            <i class="plus icon"></i>
+            <translate translate-context="Content/Profile/Button">Add new</translate>
+          </a>
+        </div>
       </h2>
       <channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
       <h2 class="ui header">
@@ -21,15 +27,61 @@
         <translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>
       </library-widget>
     </div>
+
+    <modal :show.sync="showCreateModal">
+      <div class="header">
+        <translate v-if="step === 1" key="1" translate-context="Content/Channel/*/Verb">Create channel</translate>
+        <translate v-else-if="category === 'podcast'" key="2" translate-context="Content/Channel/*">Podcast channel</translate>
+        <translate v-else key="3" translate-context="Content/Channel/*">Artist channel</translate>
+      </div>
+      <div class="scrolling content" ref="modalContent">
+        <channel-form
+          ref="createForm"
+          :object="null"
+          :step="step"
+          @loading="isLoading = $event"
+          @submittable="submittable = $event"
+          @category="category = $event"
+          @errored="$refs.modalContent.scrollTop = 0"
+          @created="$router.push({name: 'channels.detail', params: {id: $event.actor.preferred_username}})"></channel-form>
+          <div class="ui hidden divider"></div>
+      </div>
+      <div class="actions">
+        <div v-if="step === 1" class="ui basic deny button">
+          <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+        </div>
+        <button v-if="step > 1" class="ui basic button" @click.stop.prevent="step -= 1">
+          <translate translate-context="*/*/Button.Label/Verb">Previous step</translate>
+        </button>
+        <button v-if="step === 1" class="ui primary button" @click.stop.prevent="step += 1">
+          <translate translate-context="*/*/Button.Label">Next step</translate>
+        </button>
+        <button v-if="step === 2" :class="['ui', 'primary button', {loading: isLoading}]" type="submit" @click.prevent.stop="$refs.createForm.submit" :disabled="!submittable && !isLoading">
+          <translate translate-context="*/Channels/Button.Label">Create channel</translate>
+        </button>
+      </div>
+    </modal>
+
   </section>
 </template>
 
 <script>
+import Modal from '@/components/semantic/Modal'
 import LibraryWidget from "@/components/federation/LibraryWidget"
 import ChannelsWidget from "@/components/audio/ChannelsWidget"
+import ChannelForm from "@/components/audio/ChannelForm"
 
 export default {
   props: ['object'],
-  components: {ChannelsWidget, LibraryWidget},
+  components: {ChannelsWidget, LibraryWidget, ChannelForm, Modal},
+  data () {
+    return {
+      showCreateModal: false,
+      isLoading: false,
+      submittable: false,
+      step: 1,
+      category: 'podcast',
+    }
+  }
 }
 </script>
diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue
index e61aab28ed6c54642a6818812a118726cf3c9bfe..f7391dd57e9abbbe5b377e85a245784cb63c7d1d 100644
--- a/front/src/views/channels/DetailBase.vue
+++ b/front/src/views/channels/DetailBase.vue
@@ -5,8 +5,8 @@
     </div>
     <template v-if="object && !isLoading">
       <section class="ui head vertical stripe segment container" v-title="object.artist.name">
-        <div class="ui stackable two column grid">
-          <div class="column">
+        <div class="ui stackable grid">
+          <div class="seven wide column">
             <div class="ui two column grid">
               <div class="column">
                 <img class="huge channel-image" v-if="object.artist.cover" :src="$store.getters['instance/absoluteUrl'](object.artist.cover.medium_square_crop)">
@@ -28,10 +28,48 @@
                   </template>
                 </template>
                 <div class="ui hidden small divider"></div>
-                <a :href="rssUrl" target="_blank" class="ui icon small basic button">
+                <a @click.stop.prevent="showSubscribeModal = true" class="ui icon small basic button">
                   <i class="feed icon"></i>
                 </a>
-                <div class="ui dropdown icon small basic button" ref="dropdown" v-dropdown>
+                <modal class="tiny" :show.sync="showSubscribeModal">
+                  <div class="header">
+                    <translate translate-context="Popup/Channel/Title/Verb">Subscribe to this channel</translate>
+                  </div>
+                  <div class="scrollable content">
+                    <div class="description">
+
+                      <template v-if="$store.state.auth.authenticated">
+                        <h3>
+                          <i class="user icon"></i>
+                          <translate translate-context="Content/Channels/Header">Subscribe on Funkwhale</translate>
+                        </h3>
+                        <subscribe-button @subscribed="object.subscriptions_count += 1" @unsubscribed="object.subscriptions_count -= 1" :channel="object"></subscribe-button>
+                      </template>
+                      <template v-if="object.rss_url">
+                        <h3>
+                          <i class="feed icon"></i>
+                          <translate translate-context="Content/Channels/Header">Subscribe via RSS</translate>
+                        </h3>
+                        <p><translate translate-context="Content/Channels/Label">Copy-paste the following URL in your favorite podcasting app:</translate></p>
+                        <copy-input :value="object.rss_url" />
+                      </template>
+                      <template v-if="object.actor">
+                        <h3>
+                          <i class="bell icon"></i>
+                          <translate translate-context="Content/Channels/Header">Subscribe on the Fediverse</translate>
+                        </h3>
+                        <p><translate translate-context="Content/Channels/Label">If you're using Mastodon or other fediverse applications, you can subscribe to this account:</translate></p>
+                        <copy-input :value="`@${object.actor.full_username}`" />
+                      </template>
+                    </div>
+                  </div>
+                  <div class="actions">
+                    <div class="ui basic deny button">
+                      <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+                    </div>
+                  </div>
+                </modal>
+                <div class="ui right floated pointing dropdown icon small basic button" ref="dropdown" v-dropdown="{direction: 'downward'}">
                   <i class="ellipsis vertical icon"></i>
                   <div class="menu">
                     <div
@@ -52,27 +90,55 @@
                       <i class="share icon" /> {{ obj.label }}
                     </div>
 
-                    <div class="divider"></div>
-                    <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
-                      <i class="wrench icon"></i>
-                      <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
-                    </router-link>
+                    <template v-if="isOwner">
+                      <div class="divider"></div>
+                      <div
+                        class="item"
+                        role="button"
+                        @click.stop="showEditModal = true">
+                        <translate translate-context="*/*/*/Verb">Edit…</translate>
+                      </div>
+                      <dangerous-button
+                        :class="['ui', {loading: isLoading}, 'item']"
+                        v-if="object"
+                        @confirm="remove()">
+                        <translate translate-context="*/*/*/Verb">Delete…</translate>
+                        <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this Channel?</translate></p>
+                        <div slot="modal-content">
+                          <p><translate translate-context="Content/Moderation/Paragraph">The channel will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
+                        </div>
+                        <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
+                      </dangerous-button>
+                    </template>
+                    <template v-if="$store.state.auth.availablePermissions['library']" >
+                      <div class="divider"></div>
+                      <router-link class="basic item" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
+                        <i class="wrench icon"></i>
+                        <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
+                      </router-link>
+                    </template>
                   </div>
                 </div>
               </div>
             </div>
             <h1 class="ui header">
-              <div class="left aligned content ellipsis">
+              <div class="left aligned" :title="object.artist.name">
                 {{ object.artist.name }}
                 <div class="ui hidden very small divider"></div>
-                <div class="sub header">
+                <div class="sub header ellipsis" :title="object.actor.full_username">
                   {{ object.actor.full_username }}
                 </div>
               </div>
             </h1>
             <div class="header-buttons">
+              <div class="ui buttons" v-if="isOwner">
+                <button class="ui basic labeled icon button" @click.prevent.stop="$store.commit('channels/showUploadModal', {show: true, config: {channel: object}})">
+                  <i class="upload icon"></i>
+                  <translate translate-context="Content/Channels/Button.Label/Verb">Upload</translate>
+                </button>
+              </div>
               <div class="ui buttons">
-                <play-button :is-playable="isPlayable" class="orange" :channel="object">
+                <play-button :is-playable="isPlayable" class="orange" :artist="object.artist">
                   <translate translate-context="Content/Channels/Button.Label/Verb">Play</translate>
                 </play-button>
               </div>
@@ -90,21 +156,45 @@
                   </div>
                 </div>
                 <div class="actions">
-                  <div class="ui deny button">
+                  <div class="ui basic deny button">
                     <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
                   </div>
                 </div>
               </modal>
+              <modal :show.sync="showEditModal" v-if="isOwner">
+                <div class="header">
+                  <translate v-if="object.artist.content_category === 'podcast'" key="1" translate-context="Content/Channel/*">Podcast channel</translate>
+                  <translate v-else key="2" translate-context="Content/Channel/*">Artist channel</translate>
+
+                </div>
+                <div class="scrolling content">
+                  <channel-form
+                    ref="editForm"
+                    :object="object"
+                    @loading="edit.isLoading = $event"
+                    @submittable="edit.submittable = $event"
+                    @updated="fetchData"></channel-form>
+                    <div class="ui hidden divider"></div>
+                </div>
+                <div class="actions">
+                  <div class="ui left floated basic deny button">
+                    <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+                  </div>
+                  <button @click.stop="$refs.editForm.submit" :class="['ui', 'primary', 'confirm', {loading: edit.isLoading}, 'button']" :disabled="!edit.submittable">
+                    <translate translate-context="*/Channels/Button.Label">Update channel</translate>
+                  </button>
+                </div>
+              </modal>
             </div>
-            <div>
+            <div v-if="$store.getters['ui/layoutVersion'] === 'large'">
               <rendered-description
                 @updated="object = $event"
                 :content="object.artist.description"
                 :update-url="`channels/${object.uuid}/`"
-                :can-update="$store.state.auth.authenticated && object.attributed_to.full_username === $store.state.auth.fullUsername"></rendered-description>
+                :can-update="false"></rendered-description>
             </div>
           </div>
-          <div class="column">
+          <div class="nine wide column">
             <div class="ui secondary pointing center aligned menu">
               <router-link class="item" :exact="true" :to="{name: 'channels.detail', params: {id: id}}">
                 <translate translate-context="Content/Channels/Link">Overview</translate>
@@ -114,9 +204,7 @@
               </router-link>
             </div>
             <div class="ui hidden divider"></div>
-            <keep-alive>
-              <router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event" ></router-view>
-            </keep-alive>
+            <router-view v-if="object" :object="object" @tracks-loaded="totalTracks = $event"></router-view>
           </div>
         </div>
       </section>
@@ -135,6 +223,7 @@ import TagsList from "@/components/tags/List"
 import ReportMixin from '@/components/mixins/Report'
 
 import SubscribeButton from '@/components/channels/SubscribeButton'
+import ChannelForm from "@/components/audio/ChannelForm"
 
 export default {
   mixins: [ReportMixin],
@@ -146,7 +235,8 @@ export default {
     TagsList,
     ChannelEntries,
     ChannelSeries,
-    SubscribeButton
+    SubscribeButton,
+    ChannelForm,
   },
   data() {
     return {
@@ -155,28 +245,54 @@ export default {
       totalTracks: 0,
       latestTracks: null,
       showEmbedModal: false,
+      showEditModal: false,
+      showSubscribeModal: false,
+      edit: {
+        submittable: false,
+        loading: false,
+      }
     }
   },
+  beforeRouteUpdate (to, from, next) {
+    to.meta.preserveScrollPosition = true
+    next()
+  },
   async created() {
     await this.fetchData()
   },
   methods: {
     async fetchData() {
       var self = this
+      this.showEditModal = false
+      this.edit.isLoading = false
       this.isLoading = true
       let channelPromise = axios.get(`channels/${this.id}`).then(response => {
         self.object = response.data
-      })
-      let tracksPromise = axios.get("tracks", {params: {channel: this.id, page_size: 1, playable: true, include_channels: true}}).then(response => {
-        self.totalTracks = response.data.count
+        let tracksPromise = axios.get("tracks", {params: {channel: response.data.uuid, page_size: 1, playable: true, include_channels: true}}).then(response => {
+          self.totalTracks = response.data.count
+          self.isLoading = false
+        })
       })
       await channelPromise
-      await tracksPromise
-      self.isLoading = false
+    },
+    remove () {
+      let self = this
+      self.isLoading = true
+      axios.delete(`channels/${this.object.uuid}`).then((response) => {
+        self.isLoading = false
+        self.$emit('deleted')
+        self.$router.push({name: 'profile.overview', params: {username: self.$store.state.auth.username}})
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
     }
   },
   computed: {
-    labels() {
+    isOwner () {
+      return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
+    },
+    labels () {
       return {
         title: this.$pgettext('*/*/*', 'Channel')
       }
@@ -190,9 +306,6 @@ export default {
     isPlayable () {
       return this.totalTracks > 0
     },
-    rssUrl () {
-      return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`)
-    }
   },
   watch: {
     id() {
diff --git a/front/src/views/channels/DetailOverview.vue b/front/src/views/channels/DetailOverview.vue
index 9596e28d4d3a18c2d37e1d81e990f1fe06938865..9d35b1d1756ce817520abe1bbc93a7bca4e28623 100644
--- a/front/src/views/channels/DetailOverview.vue
+++ b/front/src/views/channels/DetailOverview.vue
@@ -1,22 +1,86 @@
 <template>
   <section>
-    <channel-entries :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
+    <div class="ui info message" v-if="pendingUploads.length > 0">
+      <template v-if="isSuccessfull">
+        <i role="button" class="close icon" @click="pendingUploads = []"></i>
+        <h3 class="ui header">
+          <translate translate-context="Content/Channel/Header">Uploads published successfully</translate>
+        </h3>
+        <p>
+          <translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }}
+        </p>
+      </template>
+      <template v-else-if="isOver">
+        <h3 class="ui header">
+          <translate translate-context="Content/Channel/Header">Some uploads couldn't be published</translate>
+        </h3>
+        <div class="ui hidden divider"></div>
+        <router-link
+          class="ui basic button"
+          :to="{name: 'content.libraries.files', query: {q: 'status:skipped'}}"
+          v-if="skippedUploads.length > 0">
+          <translate translate-context="Content/Channel/Button">View skipped uploads</translate>
+        </router-link>
+        <router-link
+          class="ui basic button"
+          :to="{name: 'content.libraries.files', query: {q: 'status:errored'}}"
+          v-if="erroredUploads.length > 0">
+          <translate translate-context="Content/Channel/Button">View errored uploads</translate>
+        </router-link>
+      </template>
+      <template v-else>
+        <div class="ui inline right floated active loader"></div>
+        <h3 class="ui header">
+          <translate translate-context="Content/Channel/Header">Uploads are being processed</translate>
+        </h3>
+        <p>
+          <translate translate-context="Content/Channel/Paragraph">Your uploads are being processed by Funkwhale and will be live very soon.</translate>
+        </p>
+        <p>
+          <translate translate-context="Content/Channel/Paragraph">Processed uploads:</translate> {{ processedUploads.length }}/{{ pendingUploads.length }}
+        </p>
+
+      </template>
+    </div>
+    <div v-if="$store.getters['ui/layoutVersion'] === 'small'">
+      <rendered-description
+        :content="object.artist.description"
+        :update-url="`channels/${object.uuid}/`"
+        :can-update="false"></rendered-description>
+        <div class="ui hidden divider"></div>
+    </div>
+    <channel-entries :key="String(episodesKey) + 'entries'" :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
       <h2 class="ui header">
         <translate translate-context="Content/Channel/Paragraph">Latest episodes</translate>
       </h2>
     </channel-entries>
     <div class="ui hidden divider"></div>
-    <channel-series :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
-      <h2 class="ui header">
+    <channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters">
+      <h2 class="ui with-actions header">
         <translate translate-context="Content/Channel/Paragraph">Series</translate>
+        <div class="actions" v-if="isOwner">
+          <a @click.stop.prevent="$refs.albumModal.show = true">
+            <i class="plus icon"></i>
+            <translate translate-context="Content/Profile/Button">Add new</translate>
+          </a>
+        </div>
       </h2>
     </channel-series>
+    <album-modal
+      ref="albumModal"
+      v-if="isOwner"
+      :channel="object"
+      @created="$refs.albumModal.show = false; seriesKey = new Date()"></album-modal>
   </section>
 </template>
 
 <script>
+import axios from 'axios'
+import qs from 'qs'
+
 import ChannelEntries from "@/components/audio/ChannelEntries"
 import ChannelSeries from "@/components/audio/ChannelSeries"
+import AlbumModal from "@/components/channels/AlbumModal"
 
 
 export default {
@@ -24,6 +88,107 @@ export default {
   components: {
     ChannelEntries,
     ChannelSeries,
+    AlbumModal,
+  },
+  data () {
+    return {
+      seriesKey: new Date(),
+      episodesKey: new Date(),
+      pendingUploads: [],
+    }
+  },
+  async created () {
+    if (this.isOwner) {
+      await this.fetchPendingUploads()
+      this.$store.commit("ui/addWebsocketEventHandler", {
+        eventName: "import.status_updated",
+        id: "fileUploadChannel",
+        handler: this.handleImportEvent
+      });
+    }
+  },
+  destroyed() {
+    this.$store.commit("ui/removeWebsocketEventHandler", {
+      eventName: "import.status_updated",
+      id: "fileUploadChannel"
+    });
+  },
+  computed: {
+    isOwner () {
+      return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
+    },
+    seriesFilters () {
+      let filters = {artist: this.object.artist.id, ordering: '-creation_date'}
+      if (!this.isOwner) {
+        filters.playable = 'true'
+      }
+      return filters
+    },
+    processedUploads () {
+      return this.pendingUploads.filter((u) => {
+        return u.import_status != "pending"
+      })
+    },
+    erroredUploads () {
+      return this.pendingUploads.filter((u) => {
+        return u.import_status === "errored"
+      })
+    },
+    skippedUploads () {
+      return this.pendingUploads.filter((u) => {
+        return u.import_status === "skipped"
+      })
+    },
+    finishedUploads () {
+      return this.pendingUploads.filter((u) => {
+        return u.import_status === "finished"
+      })
+    },
+    pendingUploadsById () {
+      let d = {}
+      this.pendingUploads.forEach((u) => {
+        d[u.uuid] = u
+      })
+      return d
+    },
+    isOver () {
+      return this.pendingUploads && this.processedUploads.length === this.pendingUploads.length
+    },
+    isSuccessfull () {
+      return this.pendingUploads && this.finishedUploads.length === this.pendingUploads.length
+    }
+  },
+  methods: {
+    handleImportEvent(event) {
+      let self = this;
+      if (!this.pendingUploadsById[event.upload.uuid]) {
+        return;
+      }
+      Object.assign(this.pendingUploadsById[event.upload.uuid], event.upload)
+    },
+    async fetchPendingUploads () {
+      let response = await axios.get('uploads/', {
+        params: {channel: this.object.uuid, import_status: ['pending', 'skipped', 'errored'], include_channels: 'true'},
+        paramsSerializer: function(params) {
+          return qs.stringify(params, { indices: false })
+        }
+      })
+      this.pendingUploads = response.data.results
+    }
   },
+  watch: {
+    "$store.state.channels.latestPublication" (v) {
+      if (v && v.uploads && v.channel.uuid === this.object.uuid) {
+        let test
+        this.pendingUploads = [...this.pendingUploads, ...v.uploads]
+      }
+    },
+    "isOver" (v) {
+      if (v) {
+        this.seriesKey = new Date()
+        this.episodesKey = new Date()
+      }
+    }
+  }
 }
 </script>
diff --git a/front/src/views/content/Home.vue b/front/src/views/content/Home.vue
index fb8128b9f29af3c111e99df3493065e1844888d5..d57ff03085edc9290db98fec10baeeb4082372ca 100644
--- a/front/src/views/content/Home.vue
+++ b/front/src/views/content/Home.vue
@@ -2,21 +2,39 @@
   <section class="ui vertical aligned stripe segment" v-title="labels.title">
     <div class="ui text container">
       <h1>{{ labels.title }}</h1>
-      <p><translate translate-context="Content/Library/Paragraph">There are various ways to grab new content and make it available here.</translate></p>
+      <p>
+        <strong><translate translate-context="Content/Library/Paragraph" :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space for every user.</translate></strong>
+      </p>
       <div class="ui segment">
-        <h2><translate translate-context="Content/Library/Title/Verb">Upload audio content</translate></h2>
-        <p><translate translate-context="Content/Library/Paragraph">Upload music files (MP3, OGG, FLAC, etc.) from your personal library directly from your browser to enjoy them here.</translate></p>
+        <h2>
+          <i class="feed icon"></i>&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>
diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue
index a00442d1a73974adfe0bf0c35548ee0b0cc4a75c..79c498a3e0b3fd8a1450b4133d39e3712bab1757 100644
--- a/front/src/views/content/libraries/FilesTable.vue
+++ b/front/src/views/content/libraries/FilesTable.vue
@@ -28,6 +28,9 @@
             <option value>
               <translate translate-context="Content/*/Dropdown">All</translate>
             </option>
+            <option value="draft">
+              <translate translate-context="Content/Library/*/Short">Draft</translate>
+            </option>
             <option value="pending">
               <translate translate-context="Content/Library/*/Short">Pending</translate>
             </option>
@@ -258,7 +261,8 @@ export default {
           page: this.page,
           page_size: this.paginateBy,
           ordering: this.getOrderingAsString(),
-          q: this.search.query
+          q: this.search.query,
+          include_channels: 'true',
         },
         this.filters || {}
       );
@@ -288,7 +292,8 @@ export default {
     },
     actionFilters() {
       var currentFilters = {
-        q: this.search.query
+        q: this.search.query,
+        include_channels: 'true',
       };
       if (this.filters) {
         return _.merge(currentFilters, this.filters);
diff --git a/front/src/views/content/libraries/Form.vue b/front/src/views/content/libraries/Form.vue
index 48c4f8bc37c7e04710250577db50864c54939634..9918eb42d9c67e4034c2f03494aeef7b6d04d2bb 100644
--- a/front/src/views/content/libraries/Form.vue
+++ b/front/src/views/content/libraries/Form.vue
@@ -26,7 +26,7 @@
       <translate translate-context="Content/Library/Button.Label/Verb" v-if="library">Update library</translate>
       <translate translate-context="Content/Library/Button.Label/Verb" v-else>Create library</translate>
     </button>
-    <dangerous-button v-if="library" class="right floated basic button" color='red' @confirm="remove()">
+    <dangerous-button v-if="library" class="ui right floated basic red button" @confirm="remove()">
       <translate translate-context="*/*/*/Verb">Delete</translate>
       <p slot="modal-header">
         <translate translate-context="Popup/Library/Title">Delete this library?</translate>
diff --git a/front/src/views/content/libraries/Quota.vue b/front/src/views/content/libraries/Quota.vue
index f043b54b67d187abb629593666f99ddd9199cbe5..8b1fcba7eac3af3bdac47c2ca53efbb6c2b12faa 100644
--- a/front/src/views/content/libraries/Quota.vue
+++ b/front/src/views/content/libraries/Quota.vue
@@ -31,8 +31,7 @@
           </router-link>
 
           <dangerous-button
-            color="grey"
-            class="basic tiny"
+            class="ui basic tiny grey button"
             :action="purgePendingFiles">
             <translate translate-context="*/*/*/Verb">Purge</translate>
             <p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge pending files?</translate></p>
@@ -57,8 +56,7 @@
             <translate translate-context="Content/Library/Link/Verb">View files</translate>
           </router-link>
           <dangerous-button
-            color="grey"
-            class="basic tiny"
+            class="ui basic tiny grey button"
             :action="purgeSkippedFiles">
             <translate translate-context="*/*/*/Verb">Purge</translate>
             <p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge skipped files?</translate></p>
@@ -83,8 +81,7 @@
             <translate translate-context="Content/Library/Link/Verb">View files</translate>
           </router-link>
           <dangerous-button
-            color="grey"
-            class="basic tiny"
+            class="ui basic tiny grey button"
             :action="purgeErroredFiles">
             <translate translate-context="*/*/*/Verb">Purge</translate>
             <p slot="modal-header"><translate translate-context="Popup/Library/Title">Purge errored files?</translate></p>
@@ -154,4 +151,4 @@ export default {
     }
   }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue
index 1676c073b82457ce390866712ffedba1539f4fb3..2ad16c03f0fe71db69b0e8e9c140d2b3ea87a9e6 100644
--- a/front/src/views/content/remote/Card.vue
+++ b/front/src/views/content/remote/Card.vue
@@ -115,7 +115,6 @@
         </template>
         <template v-else-if="library.follow.approved">
           <dangerous-button
-            color=""
             :class="['ui', 'button']"
             :action="unfollow">
             <translate translate-context="*/Library/Button.Label/Verb">Unfollow</translate>
diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue
index 5dc99e78b97ebb6ef286b2be928ddbefd8a20e88..a58c4d05bd9a1f8df7fb801c64e0ac6894be4d8b 100644
--- a/front/src/views/playlists/Detail.vue
+++ b/front/src/views/playlists/Detail.vue
@@ -39,7 +39,7 @@
           <translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
         </button>
 
-        <dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="labeled icon" :action="deletePlaylist">
+        <dangerous-button v-if="$store.state.auth.profile && playlist.user.id === $store.state.auth.profile.id" class="ui labeled red icon button" :action="deletePlaylist">
           <i class="trash icon"></i> <translate translate-context="*/*/*/Verb">Delete</translate>
           <p slot="modal-header" v-translate="{playlist: playlist.name}" translate-context="Popup/Playlist/Title/Call to action" :translate-params="{playlist: playlist.name}">
             Do you want to delete the playlist "%{ playlist }"?
@@ -58,7 +58,7 @@
           </div>
         </div>
         <div class="actions">
-          <div class="ui deny button">
+          <div class="ui basic deny button">
             <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
           </div>
         </div>
diff --git a/front/src/views/radios/Detail.vue b/front/src/views/radios/Detail.vue
index 619ac754b3aaff12d59b0928c6fcd035a3baf1c3..4610d6873863de485e50de2607de3b5e7c52b8e7 100644
--- a/front/src/views/radios/Detail.vue
+++ b/front/src/views/radios/Detail.vue
@@ -22,7 +22,7 @@
             <i class="pencil icon"></i>
             Edit…
           </router-link>
-          <dangerous-button class="labeled icon" :action="deleteRadio">
+          <dangerous-button class="ui labeled red icon button" :action="deleteRadio">
             <i class="trash icon"></i> Delete
             <p slot="modal-header" v-translate="{radio: radio.name}"  translate-context="Popup/Radio/Title" :translate-params="{radio: radio.name}">Do you want to delete the radio "%{ radio }"?</p>
             <p slot="modal-content"><translate translate-context="Popup/Radio/Paragraph">This will completely delete this radio and cannot be undone.</translate></p>
@@ -53,7 +53,7 @@
       </div>
       <router-link
       v-if="$store.state.auth.username === radio.user.username"
-      class="ui green icon labeled button" 
+      class="ui green icon labeled button"
       :to="{name: 'library.radios.edit', params: {id: radio.id}}" exact>
       <i class="pencil icon"></i>
         Edit…
diff --git a/front/tests/unit/specs/utils.spec.js b/front/tests/unit/specs/utils.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..9fc9c36b7a57deb5622ff7231f7ccb8b2d4046b2
--- /dev/null
+++ b/front/tests/unit/specs/utils.spec.js
@@ -0,0 +1,32 @@
+import {expect} from 'chai'
+
+import {parseAPIErrors} from '@/utils'
+
+describe('utils', () => {
+  describe('parseAPIErrors', () => {
+    it('handles flat structure', () => {
+      const input = {"old_password": ["Invalid password"]}
+      let expected = ["Invalid password"]
+      let output = parseAPIErrors(input)
+      expect(output).to.deep.equal(expected)
+    })
+    it('handles flat structure with multiple errors per field', () => {
+      const input = {"old_password": ["Invalid password", "Too short"]}
+      let expected = ["Invalid password", "Too short"]
+      let output = parseAPIErrors(input)
+      expect(output).to.deep.equal(expected)
+    })
+    it('translate field name', () => {
+      const input = {"old_password": ["This field is required"]}
+      let expected = ["Old Password: This field is required"]
+      let output = parseAPIErrors(input)
+      expect(output).to.deep.equal(expected)
+    })
+    it('handle nested fields', () => {
+      const input = {"summary": {"text": ["Ensure this field has no more than 5000 characters."]}}
+      let expected = ["Summary - Text: Ensure this field has no more than 5000 characters."]
+      let output = parseAPIErrors(input)
+      expect(output).to.deep.equal(expected)
+    })
+  })
+})
diff --git a/front/yarn.lock b/front/yarn.lock
index 7456a35833a8d5afb9eb5616c694a46a2244357f..5ef45672914b6339ac58f944c3eaaba9608d1ba0 100644
--- a/front/yarn.lock
+++ b/front/yarn.lock
@@ -3243,11 +3243,6 @@ date-now@^0.1.4:
   resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
   integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=
 
-dateformat@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
-  integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
-
 de-indent@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
@@ -4267,10 +4262,10 @@ follow-redirects@^1.0.0:
   dependencies:
     debug "^3.2.6"
 
-fomantic-ui-css@^2.7:
-  version "2.7.6"
-  resolved "https://registry.yarnpkg.com/fomantic-ui-css/-/fomantic-ui-css-2.7.6.tgz#8af84c0afce21142bf663979cf7452155562e6e2"
-  integrity sha512-oruD/DoMDZGSfK6fE3EnWKGad3vbhpiOtXrCwS0Bi+3QWXHwQsDU0k6P0Q8HzawoLXqHff83LmTDJWST5ARTxw==
+fomantic-ui-css@^2.8.3:
+  version "2.8.3"
+  resolved "https://registry.yarnpkg.com/fomantic-ui-css/-/fomantic-ui-css-2.8.3.tgz#19689dcaf2f6a362a2eb1492bef42134a4658541"
+  integrity sha512-DvWMUjssWRCuKl4dHkibusE/Ewe1udUztAEbdPkLz3ou8XYjiUeUTX/0sMCFFyo5/S84x+vW8I9juafmtOJxyQ==
   dependencies:
     jquery "^3.4.0"