diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index 7339df445d43653e7580220e528dd89a9b24b125..b50066f3dbdada8e01c80a909a264bea13bf34d0 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -88,6 +88,9 @@ v1_patterns += [
     url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"),
     url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"),
     url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
+    url(
+        r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
+    ),
 ]
 
 urlpatterns = [
diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py
index 0c9732efeed361263393c6837d1e72b245243995..2d46b8cce4115507ec61a3035c50c68e7c11191c 100644
--- a/api/funkwhale_api/audio/serializers.py
+++ b/api/funkwhale_api/audio/serializers.py
@@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer):
 
     @transaction.atomic
     def create(self, validated_data):
+        from . import views
+
         description = validated_data.get("description")
         artist = music_models.Artist.objects.create(
             attributed_to=validated_data["attributed_to"],
@@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer):
             actor=validated_data["attributed_to"],
         )
         channel.save()
+        channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
         return channel
 
     def to_representation(self, obj):
-        return ChannelSerializer(obj).data
+        return ChannelSerializer(obj, context=self.context).data
 
 
 NOOP = object()
@@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
         return obj
 
     def to_representation(self, obj):
-        return ChannelSerializer(obj).data
+        return ChannelSerializer(obj, context=self.context).data
 
 
 class ChannelSerializer(serializers.ModelSerializer):
@@ -261,7 +264,8 @@ def rss_serialize_item(upload):
         "link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
         "enclosure": [
             {
-                "url": upload.listen_url,
+                # 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",
             }
@@ -271,7 +275,6 @@ def rss_serialize_item(upload):
         data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
         data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
         data["description"] = [{"value": upload.track.description.as_plain_text}]
-        data["content:encoded"] = data["itunes:summary"]
 
     if upload.track.attachment_cover:
         data["itunes:image"] = [
diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py
index 1f40dd0a613943cd60b9c91375461f79ff62c953..09ae6d0cfafd1e644ccab0078f8be3ef41f9ddef 100644
--- a/api/funkwhale_api/audio/views.py
+++ b/api/funkwhale_api/audio/views.py
@@ -6,7 +6,7 @@ from rest_framework import response
 from rest_framework import viewsets
 
 from django import http
-from django.db.models import Prefetch
+from django.db.models import Count, Prefetch
 from django.db.utils import IntegrityError
 
 from funkwhale_api.common import permissions
@@ -18,6 +18,12 @@ from funkwhale_api.users.oauth import permissions as oauth_permissions
 
 from . import filters, models, renderers, serializers
 
+ARTIST_PREFETCH_QS = (
+    music_models.Artist.objects.select_related("description", "attachment_cover",)
+    .prefetch_related(music_views.TAG_PREFETCH)
+    .annotate(_tracks_count=Count("tracks"))
+)
+
 
 class ChannelsMixin(object):
     def dispatch(self, request, *args, **kwargs):
@@ -44,12 +50,7 @@ class ChannelViewSet(
             "library",
             "attributed_to",
             "actor",
-            Prefetch(
-                "artist",
-                queryset=music_models.Artist.objects.select_related(
-                    "attachment_cover", "description"
-                ).prefetch_related(music_views.TAG_PREFETCH,),
-            ),
+            Prefetch("artist", queryset=ARTIST_PREFETCH_QS),
         )
         .order_by("-creation_date")
     )
@@ -131,7 +132,12 @@ class ChannelViewSet(
 
     def get_serializer_context(self):
         context = super().get_serializer_context()
-        context["subscriptions_count"] = self.action in ["retrieve", "create", "update"]
+        context["subscriptions_count"] = self.action in [
+            "retrieve",
+            "create",
+            "update",
+            "partial_update",
+        ]
         return context
 
 
@@ -148,8 +154,8 @@ class SubscriptionsViewSet(
         .prefetch_related(
             "target__channel__library",
             "target__channel__attributed_to",
-            "target__channel__artist__description",
             "actor",
+            Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS),
         )
         .order_by("-creation_date")
     )
@@ -171,10 +177,12 @@ class SubscriptionsViewSet(
         to have a performant endpoint and avoid lots of queries just to display
         subscription status in the UI
         """
-        subscriptions = list(self.get_queryset().values_list("uuid", flat=True))
+        subscriptions = list(
+            self.get_queryset().values_list("uuid", "target__channel__uuid")
+        )
 
         payload = {
-            "results": [str(u) for u in subscriptions],
+            "results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions],
             "count": len(subscriptions),
         }
         return response.Response(payload, status=200)
diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py
index 953904bfa24480e0368def211ac4acbebea6e23b..df27a312aa6b48424814b30ab7cd2c031d2f30f9 100644
--- a/api/funkwhale_api/common/filters.py
+++ b/api/funkwhale_api/common/filters.py
@@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter):
         super().__init__(*args, **kwargs)
 
     def filter(self, queryset, value):
+        from funkwhale_api.federation import models as federation_models
+
         if not value:
             return queryset
 
@@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter):
             qs = self.filter_me(user=user, queryset=queryset)
         elif value.lower() == "all":
             return queryset
+        elif value.lower().startswith("actor:"):
+            full_username = value.split("actor:", 1)[1]
+            username, domain = full_username.split("@")
+            try:
+                actor = federation_models.Actor.objects.get(
+                    preferred_username=username, domain_id=domain,
+                )
+            except federation_models.Actor.DoesNotExist:
+                return queryset.none()
+
+            return queryset.filter(**{self.actor_field: actor})
         else:
             return queryset.none()
 
diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py
index 902fb0f5c5b132384582985772cf7112e542a14f..ec2c1d1f83975210d0e2698ecf40ad96622c33ac 100644
--- a/api/funkwhale_api/common/models.py
+++ b/api/funkwhale_api/common/models.py
@@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet):
 
 class Attachment(models.Model):
     # Remote URL where the attachment can be fetched
-    url = models.URLField(max_length=500, null=True)
+    url = models.URLField(max_length=500, null=True, blank=True)
     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
     # Actor associated with the attachment
     actor = models.ForeignKey(
diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index 51efce01933c5ec6a8dcfb6ce8b7a7345875fca7..5b9b5bf2d77c1af1125609c7bd785427230243a1 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -303,6 +303,7 @@ def attach_content(obj, field, content_data):
 
     if existing:
         getattr(obj, field).delete()
+        setattr(obj, field, None)
 
     if not content_data:
         return
diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py
index 58758773de02be354442f3e686b1f766cf8b687c..1611d8e63edad9d8b5b28efc25954947b824893a 100644
--- a/api/funkwhale_api/common/views.py
+++ b/api/funkwhale_api/common/views.py
@@ -181,3 +181,15 @@ class AttachmentViewSet(
         if instance.actor is None or instance.actor != self.request.user.actor:
             raise exceptions.PermissionDenied()
         instance.delete()
+
+
+class TextPreviewView(views.APIView):
+    permission_classes = []
+
+    def post(self, request, *args, **kwargs):
+        payload = request.data
+        if "text" not in payload:
+            return response.Response({"detail": "Invalid input"}, status=400)
+
+        data = {"rendered": utils.render_html(payload["text"], "text/markdown")}
+        return response.Response(data, status=200)
diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py
index 2a1143ec70bc63c2cc10408643d60aa588431ffb..bd8bfcf01375a311f26303f46e8c09f1a1257386 100644
--- a/api/funkwhale_api/federation/api_serializers.py
+++ b/api/funkwhale_api/federation/api_serializers.py
@@ -1,7 +1,10 @@
+from django.core.exceptions import ObjectDoesNotExist
+
 from rest_framework import serializers
 
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.music import models as music_models
+from funkwhale_api.users import serializers as users_serializers
 
 from . import filters
 from . import models
@@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer):
             "creation_date",
             "fetch_date",
         ]
+
+
+class FullActorSerializer(serializers.Serializer):
+    fid = serializers.URLField()
+    url = serializers.URLField()
+    domain = serializers.CharField(source="domain_id")
+    creation_date = serializers.DateTimeField()
+    last_fetch_date = serializers.DateTimeField()
+    name = serializers.CharField()
+    preferred_username = serializers.CharField()
+    full_username = serializers.CharField()
+    type = serializers.CharField()
+    is_local = serializers.BooleanField()
+    is_channel = serializers.SerializerMethodField()
+    manually_approves_followers = serializers.BooleanField()
+    user = users_serializers.UserBasicSerializer()
+    summary = common_serializers.ContentSerializer(source="summary_obj")
+    icon = common_serializers.AttachmentSerializer(source="attachment_icon")
+
+    def get_is_channel(self, o):
+        try:
+            return bool(o.channel)
+        except ObjectDoesNotExist:
+            return False
diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py
index 4f0471b171b2a699200352babb1fcfd0e27b908d..df5bfb2f0396af3da21357aba967d51d941cad9a 100644
--- a/api/funkwhale_api/federation/api_urls.py
+++ b/api/funkwhale_api/federation/api_urls.py
@@ -8,5 +8,6 @@ router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-fol
 router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
 router.register(r"libraries", api_views.LibraryViewSet, "libraries")
 router.register(r"domains", api_views.DomainViewSet, "domains")
+router.register(r"actors", api_views.ActorViewSet, "actors")
 
 urlpatterns = router.urls
diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py
index 395290be96bd5e56c616dd3a710f5f1e01329166..7a39218f9f06343c5cecc2b6187c3af50662e396 100644
--- a/api/funkwhale_api/federation/api_views.py
+++ b/api/funkwhale_api/federation/api_views.py
@@ -12,6 +12,7 @@ from rest_framework import viewsets
 from funkwhale_api.common import preferences
 from funkwhale_api.common.permissions import ConditionalAuthentication
 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 activity
@@ -218,3 +219,34 @@ class DomainViewSet(
         if preferences.get("moderation__allow_list_enabled"):
             qs = qs.filter(allowed=True)
         return qs
+
+
+class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
+    queryset = models.Actor.objects.select_related(
+        "user", "channel", "summary_obj", "attachment_icon"
+    )
+    permission_classes = [ConditionalAuthentication]
+    serializer_class = api_serializers.FullActorSerializer
+    lookup_field = "full_username"
+    lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
+
+    def get_object(self):
+        queryset = self.get_queryset()
+        username, domain = self.kwargs["full_username"].split("@", 1)
+        return queryset.get(preferred_username=username, domain_id=domain)
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        qs = qs.exclude(
+            domain__instance_policy__is_active=True,
+            domain__instance_policy__block_all=True,
+        )
+        if preferences.get("moderation__allow_list_enabled"):
+            qs = qs.filter(domain__allowed=True)
+        return qs
+
+    libraries = decorators.action(methods=["get"], detail=True)(
+        music_views.get_libraries(
+            filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
+        )
+    )
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index c057799a6f7f6ab4239fd3cf574dd9749eb082b1..6501e93cf8c9bf13c36803a3b2281d2c042e3892 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -253,7 +253,6 @@ class APIActorSerializer(serializers.ModelSerializer):
     class Meta:
         model = models.Actor
         fields = [
-            "id",
             "fid",
             "url",
             "creation_date",
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index c44175e35a6b6c3429e4fa6c3fe0089d6d7d1d0f..a5bc37275ccaa9aa1a7587a7dc28c4c03b329581 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -876,6 +876,12 @@ class Upload(models.Model):
     def listen_url(self):
         return self.track.listen_url + "?upload={}".format(self.uuid)
 
+    def get_listen_url(self, to=None):
+        url = self.listen_url
+        if to:
+            url += "&to={}".format(to)
+        return url
+
     @property
     def listen_url_no_download(self):
         # Not using reverse because this is slow
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 7b367857ef7418ca9767f6648c68043bd6014bc0..4113c3d7a56f3eee70cc73628bb0341eb45ac6f0 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -156,6 +156,19 @@ def serialize_artist_simple(artist):
             else None
         )
 
+    if "attachment_cover" in artist._state.fields_cache:
+        data["cover"] = (
+            cover_field.to_representation(artist.attachment_cover)
+            if artist.attachment_cover
+            else None
+        )
+
+    if getattr(artist, "_tracks_count", None) is not None:
+        data["tracks_count"] = artist._tracks_count
+
+    if getattr(artist, "_prefetched_tagged_items", None) is not None:
+        data["tags"] = [ti.tag.name for ti in artist._prefetched_tagged_items]
+
     return data
 
 
diff --git a/api/funkwhale_api/subsonic/renderers.py b/api/funkwhale_api/subsonic/renderers.py
index 92c4508d4d7016a8a22e4d15719c2c7b3e13bc5b..7390def8b54c39e734ead6a625e7de837ad25ba7 100644
--- a/api/funkwhale_api/subsonic/renderers.py
+++ b/api/funkwhale_api/subsonic/renderers.py
@@ -5,6 +5,29 @@ from rest_framework import renderers
 import funkwhale_api
 
 
+# from https://stackoverflow.com/a/8915039
+# because I want to avoid a lxml dependency just for outputting cdata properly
+# in a RSS feed
+def CDATA(text=None):
+    element = ET.Element("![CDATA[")
+    element.text = text
+    return element
+
+
+ET._original_serialize_xml = ET._serialize_xml
+
+
+def _serialize_xml(write, elem, qnames, namespaces, **kwargs):
+    if elem.tag == "![CDATA[":
+        write("<%s%s]]>" % (elem.tag, elem.text))
+        return
+    return ET._original_serialize_xml(write, elem, qnames, namespaces, **kwargs)
+
+
+ET._serialize_xml = ET._serialize["xml"] = _serialize_xml
+# end of tweaks
+
+
 def structure_payload(data):
     payload = {
         "funkwhaleVersion": funkwhale_api.__version__,
@@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None):
             if key == "value":
                 root.text = str(value)
             elif key == "cdata_value":
-                root.text = "<![CDATA[{}]]>".format(str(value))
+                root.append(CDATA(value))
             else:
                 root.set(key, str(value))
     return root
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 72ab2afd669ccf8d1ec18e6428f9abb99d40a087..16212e97527ee87b4eaa082fdedd27d2739d5f7e 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -229,8 +229,8 @@ class User(AbstractUser):
             self.last_activity = now
             self.save(update_fields=["last_activity"])
 
-    def create_actor(self):
-        self.actor = create_actor(self)
+    def create_actor(self, **kwargs):
+        self.actor = create_actor(self, **kwargs)
         self.save(update_fields=["actor"])
         return self.actor
 
@@ -264,15 +264,10 @@ class User(AbstractUser):
     def full_username(self):
         return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
 
-    @property
-    def avatar_path(self):
-        if not self.avatar:
-            return None
-        try:
-            return self.avatar.path
-        except NotImplementedError:
-            # external storage
-            return self.avatar.name
+    def get_avatar(self):
+        if not self.actor:
+            return
+        return self.actor.attachment_icon
 
 
 def generate_code(length=10):
@@ -399,8 +394,9 @@ def get_actor_data(username, **kwargs):
     }
 
 
-def create_actor(user):
+def create_actor(user, **kwargs):
     args = get_actor_data(user.username)
+    args.update(kwargs)
     private, public = keys.get_key_pair()
     args["private_key"] = private.decode("utf-8")
     args["public_key"] = public.decode("utf-8")
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 59986fdaa84ac107ce7102a39f1de9c4217fe64a..1e919f7b711eb139b688601e1ef8d7a4da1828f1 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
 
 
 class UserBasicSerializer(serializers.ModelSerializer):
-    avatar = serializers.SerializerMethodField()
+    avatar = common_serializers.AttachmentSerializer(source="get_avatar")
 
     class Meta:
         model = models.User
         fields = ["id", "username", "name", "date_joined", "avatar"]
 
-    def get_avatar(self, o):
-        return common_serializers.AttachmentSerializer(
-            o.actor.attachment_icon if o.actor else None
-        ).data
-
 
 class UserWriteSerializer(serializers.ModelSerializer):
     summary = common_serializers.ContentSerializer(required=False, allow_null=True)
@@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer):
             obj.actor.save(update_fields=["attachment_icon"])
         return obj
 
-    def to_representation(self, obj):
-        repr = super().to_representation(obj)
-        repr["avatar"] = common_serializers.AttachmentSerializer(
-            obj.actor.attachment_icon
-        ).data
-        return repr
-
 
 class UserReadSerializer(serializers.ModelSerializer):
 
     permissions = serializers.SerializerMethodField()
     full_username = serializers.SerializerMethodField()
-    avatar = serializers.SerializerMethodField()
+    avatar = common_serializers.AttachmentSerializer(source="get_avatar")
 
     class Meta:
         model = models.User
@@ -170,9 +158,6 @@ class UserReadSerializer(serializers.ModelSerializer):
             "avatar",
         ]
 
-    def get_avatar(self, o):
-        return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data
-
     def get_permissions(self, o):
         return o.get_permissions()
 
diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py
index f0979c0e08035bc796a776ba3313e564a47abaf2..9ef01c908aadd657eee08b19268315e4aa53107e 100644
--- a/api/tests/audio/test_serializers.py
+++ b/api/tests/audio/test_serializers.py
@@ -185,7 +185,6 @@ def test_rss_item_serializer(factories):
         "itunes:subtitle": [{"value": description.truncate(255)}],
         "itunes:summary": [{"cdata_value": description.rendered}],
         "description": [{"value": description.as_plain_text}],
-        "content:encoded": [{"cdata_value": description.rendered}],
         "guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
         "pubDate": [{"value": serializers.rss_date(upload.creation_date)}],
         "itunes:duration": [{"value": serializers.rss_duration(upload.duration)}],
@@ -197,7 +196,11 @@ def test_rss_item_serializer(factories):
         "itunes:image": [{"href": upload.track.attachment_cover.download_url_original}],
         "link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
         "enclosure": [
-            {"url": upload.listen_url, "length": upload.size, "type": upload.mimetype}
+            {
+                "url": federation_utils.full_url(upload.get_listen_url("mp3")),
+                "length": upload.size,
+                "type": upload.mimetype,
+            }
         ],
     }
 
diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py
index 7a9fb477b122282274995321a7a03d6d11a56041..9b9cf51f105c1e49023d39ec3f1286cf5295f926 100644
--- a/api/tests/audio/test_views.py
+++ b/api/tests/audio/test_views.py
@@ -1,8 +1,10 @@
+import uuid
 import pytest
 
 from django.urls import reverse
 
 from funkwhale_api.audio import serializers
+from funkwhale_api.audio import views
 
 
 def test_channel_create(logged_in_api_client):
@@ -23,8 +25,10 @@ def test_channel_create(logged_in_api_client):
 
     assert response.status_code == 201
 
-    channel = actor.owned_channels.select_related("artist__description").latest("id")
-    expected = serializers.ChannelSerializer(channel).data
+    channel = views.ChannelViewSet.queryset.get(attributed_to=actor)
+    expected = serializers.ChannelSerializer(
+        channel, context={"subscriptions_count": True}
+    ).data
 
     assert response.data == expected
     assert channel.artist.name == data["name"]
@@ -43,6 +47,9 @@ def test_channel_create(logged_in_api_client):
 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})
+    setattr(channel.artist, "_tracks_count", 0)
+    setattr(channel.artist, "_prefetched_tagged_items", [])
+
     expected = serializers.ChannelSerializer(
         channel, context={"subscriptions_count": True}
     ).data
@@ -54,6 +61,8 @@ def test_channel_detail(factories, logged_in_api_client):
 
 def test_channel_list(factories, logged_in_api_client):
     channel = factories["audio.Channel"](artist__description=None)
+    setattr(channel.artist, "_tracks_count", 0)
+    setattr(channel.artist, "_prefetched_tagged_items", [])
     url = reverse("api:v1:channels-list")
     expected = serializers.ChannelSerializer(channel).data
     response = logged_in_api_client.get(url)
@@ -142,8 +151,11 @@ def test_channel_subscribe(factories, logged_in_api_client):
     assert response.status_code == 201
 
     subscription = actor.emitted_follows.select_related(
-        "target__channel__artist__description"
+        "target__channel__artist__description",
+        "target__channel__artist__attachment_cover",
     ).latest("id")
+    setattr(subscription.target.channel.artist, "_tracks_count", 0)
+    setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
     assert subscription.fid == subscription.get_federation_id()
     expected = serializers.SubscriptionSerializer(subscription).data
     assert response.data == expected
@@ -168,6 +180,8 @@ def test_subscriptions_list(factories, logged_in_api_client):
     actor = logged_in_api_client.user.create_actor()
     channel = factories["audio.Channel"](artist__description=None)
     subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
+    setattr(subscription.target.channel.artist, "_tracks_count", 0)
+    setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
     factories["audio.Subscription"](target=channel.actor)
     url = reverse("api:v1:subscriptions-list")
     expected = serializers.SubscriptionSerializer(subscription).data
@@ -192,7 +206,10 @@ def test_subscriptions_all(factories, logged_in_api_client):
     response = logged_in_api_client.get(url)
 
     assert response.status_code == 200
-    assert response.data == {"results": [subscription.uuid], "count": 1}
+    assert response.data == {
+        "results": [{"uuid": subscription.uuid, "channel": uuid.UUID(channel.uuid)}],
+        "count": 1,
+    }
 
 
 def test_channel_rss_feed(factories, api_client):
diff --git a/api/tests/common/test_filters.py b/api/tests/common/test_filters.py
index 138f6ca5d6d51b284658be015440d1bdbb9c2388..b45dcf1115063dafe3dc57f8427e9d8fe4763f03 100644
--- a/api/tests/common/test_filters.py
+++ b/api/tests/common/test_filters.py
@@ -50,6 +50,8 @@ def test_mutation_filter_is_approved(value, expected, factories):
         ("noop", 0, []),
         ("noop", 1, []),
         ("noop", 2, []),
+        ("actor:actor1@domain.test", 0, [0]),
+        ("actor:actor2@domain.test", 0, [1]),
     ],
 )
 def test_actor_scope_filter(
@@ -61,8 +63,13 @@ def test_actor_scope_filter(
     mocker,
     anonymous_user,
 ):
-    actor1 = factories["users.User"]().create_actor()
-    actor2 = factories["users.User"]().create_actor()
+    domain = factories["federation.Domain"](name="domain.test")
+    actor1 = factories["users.User"]().create_actor(
+        preferred_username="actor1", domain=domain
+    )
+    actor2 = factories["users.User"]().create_actor(
+        preferred_username="actor2", domain=domain
+    )
     users = [actor1.user, actor2.user, anonymous_user]
     tracks = [
         factories["music.Upload"](library__actor=actor1, playable=True).track,
diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py
index 358d85736bdcb37251093acb6e1d33dcd1603da1..761a2940e6275f00e6c94dff2a07e42d897209a8 100644
--- a/api/tests/common/test_views.py
+++ b/api/tests/common/test_views.py
@@ -7,6 +7,7 @@ from funkwhale_api.common import serializers
 from funkwhale_api.common import signals
 from funkwhale_api.common import tasks
 from funkwhale_api.common import throttling
+from funkwhale_api.common import utils
 
 
 def test_can_detail_mutation(logged_in_api_client, factories):
@@ -270,3 +271,13 @@ def test_attachment_destroy_not_owner(factories, logged_in_api_client):
 
     assert response.status_code == 403
     attachment.refresh_from_db()
+
+
+def test_can_render_text_preview(api_client, db):
+    payload = {"text": "Hello world"}
+    url = reverse("api:v1:text-preview")
+    response = api_client.post(url, payload)
+
+    expected = {"rendered": utils.render_html(payload["text"], "text/markdown")}
+    assert response.status_code == 200
+    assert response.data == expected
diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py
index b81006386ac86b79a3ac34f37e19ffb89c153c40..06a309e15ac1882ff895248d326d02960b6543f5 100644
--- a/api/tests/favorites/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -29,6 +29,9 @@ def test_user_can_get_his_favorites(
             favorite, context={"request": request}
         ).data
     ]
+    expected[0]["track"]["artist"].pop("cover")
+    expected[0]["track"]["album"]["artist"].pop("cover")
+
     assert response.status_code == 200
     assert response.data["results"] == expected
 
diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py
index ab621abde4f764739c1c6b7c69654a1acbb7fb7e..5ac4d278b74494249b8d8b03f7af0a6cc8154a2a 100644
--- a/api/tests/federation/test_api_serializers.py
+++ b/api/tests/federation/test_api_serializers.py
@@ -1,7 +1,9 @@
 import pytest
 
+from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.federation import api_serializers
 from funkwhale_api.federation import serializers
+from funkwhale_api.users import serializers as users_serializers
 
 
 def test_library_serializer(factories, to_api_date):
@@ -111,3 +113,31 @@ def test_serialize_generic_relation(factory_name, factory_kwargs, expected, fact
     obj = factories[factory_name](**factory_kwargs)
     expected["type"] = factory_name
     assert api_serializers.serialize_generic_relation({}, obj) == expected
+
+
+def test_api_full_actor_serializer(factories, to_api_date):
+    summary = factories["common.Content"]()
+    icon = factories["common.Attachment"]()
+    user = factories["users.User"]()
+    actor = user.create_actor(summary_obj=summary, attachment_icon=icon)
+    expected = {
+        "fid": actor.fid,
+        "url": actor.url,
+        "creation_date": to_api_date(actor.creation_date),
+        "last_fetch_date": to_api_date(actor.last_fetch_date),
+        "user": users_serializers.UserBasicSerializer(user).data,
+        "is_channel": False,
+        "domain": actor.domain_id,
+        "type": actor.type,
+        "manually_approves_followers": actor.manually_approves_followers,
+        "full_username": actor.full_username,
+        "name": actor.name,
+        "preferred_username": actor.preferred_username,
+        "is_local": actor.is_local,
+        "summary": common_serializers.ContentSerializer(summary).data,
+        "icon": common_serializers.AttachmentSerializer(icon).data,
+    }
+
+    serializer = api_serializers.FullActorSerializer(actor)
+
+    assert serializer.data == expected
diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py
index 1795a65e418d91c68c33604434733ddfe71d99c7..73dd6b80ad8ec057437ceda9e859aff7ad602478 100644
--- a/api/tests/federation/test_api_views.py
+++ b/api/tests/federation/test_api_views.py
@@ -197,3 +197,15 @@ def test_user_can_list_domains(factories, api_client, preferences):
         "results": [api_serializers.DomainSerializer(allowed).data],
     }
     assert response.data == expected
+
+
+def test_can_retrieve_actor(factories, api_client, preferences):
+    preferences["common__api_authentication_required"] = False
+    actor = factories["federation.Actor"]()
+    url = reverse(
+        "api:v1:federation:actors-detail", kwargs={"full_username": actor.full_username}
+    )
+    response = api_client.get(url)
+
+    expected = api_serializers.FullActorSerializer(actor).data
+    assert response.data == expected
diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py
index ca7d47d6fd8e656a117d7e5040defbf394b51862..f70313f3f98b19f1f917d4437e6fef9f3991812e 100644
--- a/api/tests/music/test_serializers.py
+++ b/api/tests/music/test_serializers.py
@@ -215,6 +215,8 @@ def test_album_serializer(factories, to_api_date):
     }
     serializer = serializers.AlbumSerializer(album)
 
+    for t in expected["tracks"]:
+        t["artist"].pop("cover")
     assert serializer.data == expected
 
 
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 6ecea6306a65c7321d893fb32befbb27a36b7c3c..6df25ea634abba206b3965293474357c3d5e838f 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -1249,6 +1249,13 @@ def test_search_get(use_fts, settings, logged_in_api_client, factories):
         "tracks": [serializers.TrackSerializer(track).data],
         "tags": [views.TagSerializer(tag).data],
     }
+    for album in expected["albums"]:
+        album["artist"].pop("cover")
+
+    for track in expected["tracks"]:
+        track["artist"].pop("cover")
+        track["album"]["artist"].pop("cover")
+
     response = logged_in_api_client.get(url, {"q": "foo"})
 
     assert response.status_code == 200
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
index 2be64b2bb864ef4b4fbbb5bb98ede9fd25b9ac7a..1b7fca928da379fc946bcb114836a556fb0a7ff2 100644
--- a/api/tests/playlists/test_views.py
+++ b/api/tests/playlists/test_views.py
@@ -157,6 +157,8 @@ def test_can_list_tracks_from_playlist(level, factories, logged_in_api_client):
     url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk})
     response = logged_in_api_client.get(url)
     serialized_plt = serializers.PlaylistTrackSerializer(plt).data
+    serialized_plt["track"]["artist"].pop("cover")
+    serialized_plt["track"]["album"]["artist"].pop("cover")
 
     assert response.data["count"] == 1
     assert response.data["results"][0] == serialized_plt
diff --git a/api/tests/radios/test_api.py b/api/tests/radios/test_api.py
index 02d0dc954a72608f7e3da90a5de0af07120a1e2b..0b0ee618231b61cfcca707765974321705840ef0 100644
--- a/api/tests/radios/test_api.py
+++ b/api/tests/radios/test_api.py
@@ -36,6 +36,9 @@ def test_can_validate_config(logged_in_api_client, factories):
         "count": candidates.count(),
         "sample": TrackSerializer(candidates, many=True).data,
     }
+    for s in expected["sample"]:
+        s["artist"].pop("cover")
+
     assert payload["filters"][0]["candidates"] == expected
     assert payload["filters"][0]["errors"] == []
 
diff --git a/front/package.json b/front/package.json
index b16b83de0061bc98cec209178ebbb949f38f9f0f..d05c57d10861009e373e32b1b090ad1b8da1a6e6 100644
--- a/front/package.json
+++ b/front/package.json
@@ -14,7 +14,6 @@
   },
   "dependencies": {
     "axios": "^0.18.0",
-    "dateformat": "^3.0.3",
     "diff": "^4.0.1",
     "django-channels": "^1.1.6",
     "fomantic-ui-css": "^2.7",
diff --git a/front/src/App.vue b/front/src/App.vue
index b2de3597e83fa9308c75a4631685773d7ccd257f..b5b6eaa0f015be889accd0a75faff76bdb482647 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -24,7 +24,7 @@
       <transition name="queue">
         <queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue>
       </transition>
-      <router-view :class="{hidden: $store.state.ui.queueFocused}" :key="$route.fullPath"></router-view>
+      <router-view :class="{hidden: $store.state.ui.queueFocused}"></router-view>
       <player ref="player"></player>
       <app-footer
         :class="{hidden: $store.state.ui.queueFocused}"
@@ -241,8 +241,9 @@ export default {
     },
     getTrackInformationText(track) {
       const trackTitle = track.title
+      const albumArtist = (track.album) ? track.album.artist.name : null
       const artistName = (
-        (track.artist) ? track.artist.name : track.album.artist.name)
+        (track.artist) ? track.artist.name : albumArtist)
       const text = `♫ ${trackTitle} – ${artistName} ♫`
       return text
     },
diff --git a/front/src/EmbedFrame.vue b/front/src/EmbedFrame.vue
index 8c684d9225344d5877af09165f0665660427d438..b302c7e2d5af212109f19e95bb8b1b913e293893 100644
--- a/front/src/EmbedFrame.vue
+++ b/front/src/EmbedFrame.vue
@@ -315,7 +315,7 @@ export default {
           title: t.title,
           artist: t.artist,
           album: t.album,
-          cover: self.getCover(t.album.cover),
+          cover: self.getCover((t.album || {}).cover),
           sources: self.getSources(t.uploads)
         }
       })
diff --git a/front/src/components/Queue.vue b/front/src/components/Queue.vue
index f03b12c6273793a9b596cebd628b5f5c3fb4ef27..23f5539b0a2867b0582a6549ce53251297c4aeb0 100644
--- a/front/src/components/Queue.vue
+++ b/front/src/components/Queue.vue
@@ -6,7 +6,7 @@
                     <div class="ui six wide column current-track">
             <div class="ui basic segment" id="player">
               <template v-if="currentTrack">
-                <img class="ui image" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.square_crop)">
+                <img class="ui image" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.square_crop)">
                 <img class="ui image" v-else src="../assets/audio/default-cover.png">
                 <h1 class="ui header">
                   <div class="content">
@@ -15,9 +15,9 @@
                     </router-link>
                     <div class="sub header">
                       <router-link class="discrete link artist" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
-                        {{ currentTrack.artist.name | truncate(35) }}</router-link> /<router-link class="discrete link album" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
+                        {{ currentTrack.artist.name | truncate(35) }}</router-link> <template v-if="currentTrack.album">/<router-link class="discrete link album" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
                         {{ currentTrack.album.title | truncate(35) }}
-                      </router-link>
+                      </router-link></template>
                     </div>
                   </div>
                 </h1>
@@ -167,7 +167,7 @@
                     <i class="grip lines grey icon"></i>
                   </td>
                   <td class="image-cell" @click="$store.dispatch('queue/currentIndex', index)">
-                    <img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.square_crop)">
+                    <img class="ui mini image" v-if="currentTrack.album && track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.square_crop)">
                     <img class="ui mini image" v-else src="../assets/audio/default-cover.png">
                   </td>
                   <td colspan="3" @click="$store.dispatch('queue/currentIndex', index)">
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index fdf36f88a36f75c08654f3cc407dd76ca09ac795..fc6fb648cfec0a1546494e3bec42811a5895c5ff 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -74,10 +74,10 @@
         </router-link>
         <div class="item">
           <div class="ui user-dropdown dropdown" >
-            <img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
+            <img class="ui avatar image" v-if="$store.state.auth.profile.avatar && $store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
             <actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" />
             <div class="menu">
-              <router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
+              <router-link class="item" :to="{name: 'profile.overview', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
               <router-link class="item" :to="{path: '/settings'}"></i><translate translate-context="*/*/*/Noun">Settings</translate></router-link>
               <router-link class="item" :to="{name: 'logout'}"></i><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link>
             </div>
@@ -155,7 +155,6 @@ import { mapState, mapActions, mapGetters } from "vuex"
 
 import Logo from "@/components/Logo"
 import SearchBar from "@/components/audio/SearchBar"
-import backend from "@/audio/backend"
 
 import $ from "jquery"
 
@@ -168,7 +167,6 @@ export default {
   data() {
     return {
       selectedTab: "library",
-      backend: backend,
       isCollapsed: true,
       fetchInterval: null,
       exploreExpanded: false,
diff --git a/front/src/components/audio/ChannelCard.vue b/front/src/components/audio/ChannelCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6cb1ae4165ede5954a005eb0e081d853e1366bad
--- /dev/null
+++ b/front/src/components/audio/ChannelCard.vue
@@ -0,0 +1,55 @@
+<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">
+      <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}}">
+          {{ object.artist.name }}
+        </router-link>
+      </strong>
+      <div class="description">
+        <tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.artist.tags"></tags-list>
+      </div>
+    </div>
+    <div class="extra content">
+      <translate translate-context="Content/Channel/Paragraph"
+        translate-plural="%{ count } episodes"
+        :translate-n="object.artist.tracks_count"
+        :translate-params="{count: object.artist.tracks_count}">
+        %{ count } episode
+      </translate>
+      <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import PlayButton from '@/components/audio/PlayButton'
+import TagsList from "@/components/tags/List"
+
+export default {
+  props: {
+    object: {type: Object},
+  },
+  components: {
+    PlayButton,
+    TagsList
+  },
+  computed: {
+    imageUrl () {
+      let url = '../../assets/audio/default-cover.png'
+
+      if (this.object.artist.cover) {
+        url = this.$store.getters['instance/absoluteUrl'](this.object.artist.cover.medium_square_crop)
+      } else {
+        return null
+      }
+      return url
+    }
+  }
+}
+</script>
diff --git a/front/src/components/audio/ChannelEntries.vue b/front/src/components/audio/ChannelEntries.vue
new file mode 100644
index 0000000000000000000000000000000000000000..99ff45942063991d4ce5bdb521c0b4ea9aa2636c
--- /dev/null
+++ b/front/src/components/audio/ChannelEntries.vue
@@ -0,0 +1,74 @@
+<template>
+  <div>
+    <slot></slot>
+    <div class="ui hidden divider"></div>
+    <div v-if="isLoading" class="ui inverted active dimmer">
+      <div class="ui loader"></div>
+    </div>
+    <channel-entry-card v-for="entry in objects" :entry="entry" :key="entry.id" />
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
+    <template v-if="!isLoading && objects.length === 0">
+      <div class="ui placeholder segment">
+        <div class="ui icon header">
+          <i class="compact disc icon"></i>
+          No results matching your query
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import _ from '@/lodash'
+import axios from 'axios'
+import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
+
+export default {
+  props: {
+    filters: {type: Object, required: true},
+    limit: {type: Number, default: 5},
+  },
+  components: {
+    ChannelEntryCard
+  },
+  data () {
+    return {
+      objects: [],
+      count: 0,
+      isLoading: false,
+      errors: null,
+      nextPage: null
+    }
+  },
+  created () {
+    this.fetchData('tracks/')
+  },
+  methods: {
+    fetchData (url) {
+      if (!url) {
+        return
+      }
+      this.isLoading = true
+      let self = this
+      let params = _.clone(this.filters)
+      params.page_size = this.limit
+      params.include_channels = true
+      axios.get(url, {params: params}).then((response) => {
+        self.nextPage = response.data.next
+        self.isLoading = false
+        self.objects = self.objects.concat(response.data.results)
+        self.count = response.data.count
+        self.$emit('fetched', response.data)
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+  }
+}
+</script>
diff --git a/front/src/components/audio/ChannelEntryCard.vue b/front/src/components/audio/ChannelEntryCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1bdbd6839420f55a63586ec8806a04d663bb08c3
--- /dev/null
+++ b/front/src/components/audio/ChannelEntryCard.vue
@@ -0,0 +1,63 @@
+<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-else src="../../assets/audio/default-cover.png">
+    <div class="content">
+      <strong>
+        <router-link class="discrete ellipsis link" :title="entry.title" :to="{name: 'library.tracks.detail', params: {id: entry.id}}">
+          {{ entry.title|truncate(30) }}
+        </router-link>
+      </strong>
+      <div class="description">
+        <human-date :date="entry.creation_date"></human-date><template v-if="duration"> ·
+        <human-duration :duration="duration"></human-duration></template>
+      </div>
+    </div>
+    <div class="controls">
+      <play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :track="entry"></play-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import PlayButton from '@/components/audio/PlayButton'
+
+export default {
+  props: ['entry'],
+  components: {
+    PlayButton,
+  },
+  computed: {
+    imageUrl () {
+      let url = '../../assets/audio/default-cover.png'
+      let cover = this.cover
+      if (cover && cover.original) {
+        url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop)
+      } else {
+        return null
+      }
+      return url
+    },
+    cover () {
+      if (this.entry.album && this.entry.album.cover) {
+        return this.entry.album.cover
+      }
+    },
+    duration () {
+      let uploads = this.entry.uploads.filter((e) => {
+        return e.duration
+      })
+      if (uploads.length > 0) {
+        return uploads[0].duration
+      }
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+.default-cover {
+  background-image: url("../../assets/audio/default-cover.png") !important;
+}
+</style>
diff --git a/front/src/components/audio/ChannelSerieCard.vue b/front/src/components/audio/ChannelSerieCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7d4e246e510c31850e335ae4699dc4fb63c9072e
--- /dev/null
+++ b/front/src/components/audio/ChannelSerieCard.vue
@@ -0,0 +1,69 @@
+<template>
+  <div class="channel-serie-card">
+    <div class="two-images">
+      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
+      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
+      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
+      <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
+    </div>
+    <div class="content">
+      <strong>
+        <router-link class="discrete ellipsis link" :title="serie.title" :to="{name: 'library.albums.detail', params: {id: serie.id}}">
+          {{ serie.title|truncate(30) }}
+        </router-link>
+      </strong>
+      <div class="description">
+        <translate translate-context="Content/Channel/Paragraph"
+          translate-plural="%{ count } episodes"
+          :translate-n="serie.tracks.length"
+          :translate-params="{count: serie.tracks.length}">
+          %{ count } episode
+        </translate>
+      </div>
+    </div>
+    <div class="controls">
+      <play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :album="serie"></play-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import PlayButton from '@/components/audio/PlayButton'
+
+export default {
+  props: ['serie'],
+  components: {
+    PlayButton,
+  },
+  computed: {
+    imageUrl () {
+      let url = '../../assets/audio/default-cover.png'
+      let cover = this.cover
+      if (cover && cover.original) {
+        url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop)
+      } else {
+        return null
+      }
+      return url
+    },
+    cover () {
+      if (this.serie.cover) {
+        return this.serie.cover
+      }
+    },
+    duration () {
+      let uploads = this.serie.uploads.filter((e) => {
+        return e.duration
+      })
+      return uploads[0].duration
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+.default-cover {
+  background-image: url("../../assets/audio/default-cover.png") !important;
+}
+</style>
diff --git a/front/src/components/audio/ChannelSeries.vue b/front/src/components/audio/ChannelSeries.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0de82fbd3538b97f3f23e4589f8f6284059694d0
--- /dev/null
+++ b/front/src/components/audio/ChannelSeries.vue
@@ -0,0 +1,73 @@
+<template>
+  <div>
+    <slot></slot>
+    <div class="ui hidden divider"></div>
+    <div v-if="isLoading" class="ui inverted active dimmer">
+      <div class="ui loader"></div>
+    </div>
+    <channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
+    <template v-if="!isLoading && objects.length === 0">
+      <div class="ui placeholder segment">
+        <div class="ui icon header">
+          <i class="compact disc icon"></i>
+          No results matching your query
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import _ from '@/lodash'
+import axios from 'axios'
+import ChannelSerieCard from '@/components/audio/ChannelSerieCard'
+
+export default {
+  props: {
+    filters: {type: Object, required: true},
+    limit: {type: Number, default: 5},
+  },
+  components: {
+    ChannelSerieCard
+  },
+  data () {
+    return {
+      objects: [],
+      count: 0,
+      isLoading: false,
+      errors: null,
+      nextPage: null
+    }
+  },
+  created () {
+    this.fetchData('albums/')
+  },
+  methods: {
+    fetchData (url) {
+      if (!url) {
+        return
+      }
+      this.isLoading = true
+      let self = this
+      let params = _.clone(this.filters)
+      params.page_size = this.limit
+      params.include_channels = true
+      axios.get(url, {params: params}).then((response) => {
+        self.nextPage = response.data.next
+        self.isLoading = false
+        self.objects = self.objects.concat(response.data.results)
+        self.count = response.data.count
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+  }
+}
+</script>
diff --git a/front/src/components/audio/ChannelsWidget.vue b/front/src/components/audio/ChannelsWidget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..39fe5ffe1139a90506f498d4bc9ed6d1cef114f5
--- /dev/null
+++ b/front/src/components/audio/ChannelsWidget.vue
@@ -0,0 +1,76 @@
+<template>
+  <div>
+    <slot></slot>
+    <div class="ui hidden divider"></div>
+    <div class="ui app-cards cards">
+      <div v-if="isLoading" class="ui inverted active dimmer">
+        <div class="ui loader"></div>
+      </div>
+      <channel-card v-for="object in objects" :object="object" :key="object.uuid" />
+    </div>
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
+    <template v-if="!isLoading && objects.length === 0">
+      <div class="ui placeholder segment">
+        <div class="ui icon header">
+          <i class="compact disc icon"></i>
+          No results matching your query
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script>
+import _ from '@/lodash'
+import axios from 'axios'
+import ChannelCard from '@/components/audio/ChannelCard'
+
+export default {
+  props: {
+    filters: {type: Object, required: true},
+    limit: {type: Number, default: 5},
+  },
+  components: {
+    ChannelCard
+  },
+  data () {
+    return {
+      objects: [],
+      count: 0,
+      isLoading: false,
+      errors: null,
+      nextPage: null
+    }
+  },
+  created () {
+    this.fetchData('channels/')
+  },
+  methods: {
+    fetchData (url) {
+      if (!url) {
+        return
+      }
+      this.isLoading = true
+      let self = this
+      let params = _.clone(this.filters)
+      params.page_size = this.limit
+      params.include_channels = true
+      axios.get(url, {params: params}).then((response) => {
+        self.nextPage = response.data.next
+        self.isLoading = false
+        self.objects = self.objects.concat(response.data.results)
+        self.count = response.data.count
+        self.$emit('fetched', response.data)
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+  }
+}
+</script>
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 4c9127f4ef3a7bb937c7124883e8922e8c1aca99..822593abae9fcf57de2c17b07237227954f94312 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -10,7 +10,7 @@
 
         <div class="controls track-controls queue-not-focused desktop-and-up">
           <div @click.stop.prevent="" class="ui tiny image" @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})">
-            <img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
+            <img ref="cover" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
             <img v-else src="../../assets/audio/default-cover.png">
           </div>
           <div @click.stop.prevent="" class="middle aligned content ellipsis">
@@ -21,15 +21,15 @@
             </strong>
             <div class="meta">
               <router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.artist.name" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">
-                {{ currentTrack.artist.name }}</router-link> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
+                {{ currentTrack.artist.name }}</router-link><template v-if="currentTrack.album"> /<router-link @click.stop.prevent="" class="discrete link" :title="currentTrack.album.title" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">
                 {{ currentTrack.album.title }}
-              </router-link>
+              </router-link></template>
             </div>
           </div>
         </div>
         <div class="controls track-controls queue-not-focused tablet-and-below">
           <div class="ui tiny image">
-            <img ref="cover" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
+            <img ref="cover" v-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
             <img v-else src="../../assets/audio/default-cover.png">
           </div>
           <div class="middle aligned content ellipsis">
@@ -37,7 +37,7 @@
               {{ currentTrack.title }}
             </strong>
             <div class="meta">
-              {{ currentTrack.artist.name }} / {{ currentTrack.album.title }}
+              {{ currentTrack.artist.name }}<template v-if="currentTrack.album"> / {{ currentTrack.album.title }}</template>
             </div>
           </div>
         </div>
@@ -703,19 +703,22 @@ export default {
         // If the session is playing as a PWA, populate the notification
         // with details from the track
         if ('mediaSession' in navigator) {
-        navigator.mediaSession.metadata = new MediaMetadata({
-          title: this.currentTrack.title,
-          artist: this.currentTrack.artist.name,
-          album: this.currentTrack.album.title,
-          artwork: [
-            { src: this.currentTrack.album.cover.original, sizes: '96x96',   type: 'image/png' },
-            { src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
-            { src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
-            { src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' },
-            { src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' },
-            { src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' },
+          let metatata = {
+            title: this.currentTrack.title,
+            artist: this.currentTrack.artist.name,
+          }
+          if (this.currentTrack.album) {
+            metadata.album = this.currentTrack.album.title
+            metadata.artwork = [
+              { src: this.currentTrack.album.cover.original, sizes: '96x96',   type: 'image/png' },
+              { src: this.currentTrack.album.cover.original, sizes: '128x128', type: 'image/png' },
+              { src: this.currentTrack.album.cover.original, sizes: '192x192', type: 'image/png' },
+              { src: this.currentTrack.album.cover.original, sizes: '256x256', type: 'image/png' },
+              { src: this.currentTrack.album.cover.original, sizes: '384x384', type: 'image/png' },
+              { src: this.currentTrack.album.cover.original, sizes: '512x512', type: 'image/png' },
             ]
-          });
+          }
+          navigator.mediaSession.metadata = new MediaMetadata(metadata);
         }
       },
       immediate: false
diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue
index ed18805aa83972406f73d04c909ea1db644db031..51e78f1a3faac90688c6026a0d18ec69dcca7296 100644
--- a/front/src/components/audio/SearchBar.vue
+++ b/front/src/components/audio/SearchBar.vue
@@ -109,7 +109,11 @@ export default {
                 return r.title
               },
               getDescription (r) {
-                return `${r.album.artist.name} - ${r.album.title}`
+                if (r.album) {
+                  return `${r.album.artist.name} - ${r.album.title}`
+                } else {
+                  return r.artist.name
+                }
               },
               getId (t) {
                 return t.id
diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue
index b0d381a68ec907c1e7f97272db622e202abf2766..fc9cebaeb0c51366360bd7b78ddf357979606855 100644
--- a/front/src/components/audio/album/Widget.vue
+++ b/front/src/components/audio/album/Widget.vue
@@ -5,9 +5,6 @@
       <span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
     </h3>
     <slot></slot>
-    <button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
-    <button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
-    <button v-if="controls" @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
     <div class="ui hidden divider"></div>
     <div class="ui app-cards cards">
       <div v-if="isLoading" class="ui inverted active dimmer">
@@ -23,6 +20,12 @@
         </div>
       </div>
     </template>
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
   </div>
 </template>
 
@@ -68,7 +71,7 @@ export default {
         self.previousPage = response.data.previous
         self.nextPage = response.data.next
         self.isLoading = false
-        self.albums = response.data.results
+        self.albums = [...self.albums, ...response.data.results]
         self.count = response.data.count
       }, error => {
         self.isLoading = false
diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue
index bcb66e95f941a4670591c85b5d8e7e28a940e7dd..f73b7dbb121baf214a5a278759a232d2c07304b7 100644
--- a/front/src/components/audio/artist/Card.vue
+++ b/front/src/components/audio/artist/Card.vue
@@ -22,7 +22,6 @@
 </template>
 
 <script>
-import backend from '@/audio/backend'
 import PlayButton from '@/components/audio/PlayButton'
 import TagsList from "@/components/tags/List"
 
@@ -34,7 +33,6 @@ export default {
   },
   data () {
     return {
-      backend: backend,
       initialAlbums: 30,
       showAllAlbums: true,
     }
diff --git a/front/src/components/audio/artist/Widget.vue b/front/src/components/audio/artist/Widget.vue
index 4b80d3f079e9fd99a143f37c0dba4b20c6571259..d3e946a841dfe1bd80b4c0cdc9f43be063fdc367 100644
--- a/front/src/components/audio/artist/Widget.vue
+++ b/front/src/components/audio/artist/Widget.vue
@@ -4,9 +4,6 @@
       <slot name="title"></slot>
       <span class="ui tiny circular label">{{ count }}</span>
     </h3>
-    <button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
-    <button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
-    <button v-if="controls" @click="fetchData('artists/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
     <div class="ui hidden divider"></div>
     <div class="ui five app-cards cards">
       <div v-if="isLoading" class="ui inverted active dimmer">
@@ -15,6 +12,12 @@
       <artist-card :artist="artist" v-for="artist in objects" :key="artist.id"></artist-card>
     </div>
     <div v-if="!isLoading && objects.length === 0">No results matching your query.</div>
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
   </div>
 </template>
 
@@ -60,7 +63,7 @@ export default {
         self.previousPage = response.data.previous
         self.nextPage = response.data.next
         self.isLoading = false
-        self.objects = response.data.results
+        self.objects = [...self.objects, ...response.data.results]
         self.count = response.data.count
       }, error => {
         self.isLoading = false
diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue
index 1f5b23a6c44b5d056dee78b34649a7ca156451c2..00c55c696ac1e2d41d0036e1c79938136b034672 100644
--- a/front/src/components/audio/track/Row.vue
+++ b/front/src/components/audio/track/Row.vue
@@ -4,7 +4,7 @@
       <play-button :class="['basic', {orange: currentTrack && isPlaying && track.id === currentTrack.id}, 'icon']" :discrete="true" :is-playable="playable" :track="track"></play-button>
     </td>
     <td>
-      <img class="ui mini image" v-if="track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
+      <img class="ui mini image" v-if="track.album && track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
       <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
     </td>
     <td colspan="6">
@@ -30,7 +30,7 @@
       </template>
     </td>
     <td colspan="4">
-      <router-link class="album discrete link" :title="track.album.title" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
+      <router-link v-if="track.album" class="album discrete link" :title="track.album.title" :to="{name: 'library.albums.detail', params: {id: track.album.id }}">
         {{ track.album.title }}
       </router-link>
     </td>
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index b70d84c62f594b49265fb8b6aff116c234bc27eb..14515b4c324ece2abe16db9052b2cb0fb3e95b57 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -29,7 +29,6 @@
 </template>
 
 <script>
-import backend from '@/audio/backend'
 import axios from 'axios'
 
 import TrackRow from '@/components/audio/track/Row'
@@ -49,7 +48,6 @@ export default {
   },
   data () {
     return {
-      backend: backend,
       loadMoreUrl: this.nextUrl,
       isLoadingMore: false,
       additionalTracks: []
diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue
index 235659564cb63f539bab5e7e4400148743f74386..6895a8712730b2ad52d5951ae19fda4c788954e0 100644
--- a/front/src/components/audio/track/Widget.vue
+++ b/front/src/components/audio/track/Widget.vue
@@ -4,13 +4,10 @@
       <slot name="title"></slot>
       <span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
     </h3>
-    <button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button>
-    <button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button>
-    <button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
     <div v-if="count > 0" class="ui divided unstackable items">
       <div :class="['item', itemClasses]" v-for="object in objects" :key="object.id">
         <div class="ui tiny image">
-          <img v-if="object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
+          <img v-if="object.track.album && object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
           <img v-else src="../../../assets/audio/default-cover.png">
           <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button>
         </div>
@@ -62,6 +59,12 @@
         <div class="ui loader"></div>
       </div>
     </div>
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
   </div>
 </template>
 
@@ -112,14 +115,16 @@ export default {
         self.nextPage = response.data.next
         self.isLoading = false
         self.count = response.data.count
+        let newObjects
         if (self.isActivity) {
           // we have listening/favorites objects, not directly tracks
-          self.objects = response.data.results
+          newObjects = response.data.results
         } else {
-          self.objects = response.data.results.map((r) => {
+          newObjects = response.data.results.map((r) => {
             return {track: r}
           })
         }
+        self.objects = [...self.objects, ...newObjects]
       }, error => {
         self.isLoading = false
         self.errors = error.backendErrors
diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue
deleted file mode 100644
index 278f62ec0092492c733d524f6c4773c5b7575b9e..0000000000000000000000000000000000000000
--- a/front/src/components/auth/Profile.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<template>
-  <main class="main pusher" v-title="labels.usernameProfile">
-    <div v-if="isLoading" class="ui vertical segment">
-      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-    </div>
-    <template v-if="profile">
-      <div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
-        <h2 class="ui center aligned icon header">
-          <i v-if="!profile.avatar.square_crop" class="circular inverted user green icon"></i>
-          <img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](profile.avatar.square_crop)" />
-          <div class="content">
-            {{ profile.username }}
-            <div class="sub header" v-translate="{date: signupDate}" translate-context="Content/Profile/Paragraph">Member since %{ date }</div>
-          </div>
-        </h2>
-        <div class="ui basic green label">
-          <translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
-        </div>
-        <a v-if="profile.is_staff"
-          class="ui yellow label"
-          :href="$store.getters['instance/absoluteUrl']('/api/admin')"
-          target="_blank">
-          <i class="star icon"></i>
-          <translate translate-context="Content/Profile/User role">Staff member</translate>
-        </a>
-      </div>
-    </template>
-  </main>
-</template>
-
-<script>
-import { mapState } from "vuex"
-
-const dateFormat = require("dateformat")
-
-export default {
-  props: ["username"],
-  created() {
-    this.$store.dispatch("auth/fetchProfile")
-  },
-  computed: {
-    ...mapState({
-      profile: state => state.auth.profile
-    }),
-    labels() {
-      let msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile")
-      let usernameProfile = this.$gettextInterpolate(msg, {
-        username: this.username
-      })
-      return {
-        usernameProfile
-      }
-    },
-    signupDate() {
-      let d = new Date(this.profile.date_joined)
-      return dateFormat(d, "longDate")
-    },
-    isLoading() {
-      return !this.profile
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-.ui.header > img.image {
-  width: 8em;
-}
-</style>
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index 269e7ac29d2d183b4aa8d5502c866ced9aa66e1b..ff1e78b3d09070f68f4a0edc4879fd916e36e026 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -23,6 +23,7 @@
             <select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value">
               <option :value="c" v-for="c in f.choices">{{ sharedLabels.fields[f.id].choices[c] }}</option>
             </select>
+            <content-form v-if="f.type === 'content'" v-model="f.value.text"></content-form>
           </div>
           <button :class="['ui', {'loading': isLoading}, 'button']" type="submit">
             <translate translate-context="Content/Settings/Button.Label/Verb">Update settings</translate>
@@ -331,8 +332,12 @@ export default {
       settings: {
         success: false,
         errors: [],
-        order: ["privacy_level"],
+        order: ["summary", "privacy_level"],
         fields: {
+          summary: {
+            type: "content",
+            initial: this.$store.state.auth.profile.summary || {text: '', content_type: 'text/markdown'},
+          },
           privacy_level: {
             type: "dropdown",
             initial: this.$store.state.auth.profile.privacy_level,
@@ -459,7 +464,7 @@ export default {
         response => {
           logger.default.info("Password successfully changed")
           self.$router.push({
-            name: "profile",
+            name: "profile.overview",
             params: {
               username: self.$store.state.auth.username
             }
@@ -519,6 +524,9 @@ export default {
       this.settings.order.forEach(setting => {
         let conf = self.settings.fields[setting]
         s[setting] = conf.value
+        if (setting === 'summary' && !conf.value.text) {
+          s[setting] = null
+        }
       })
       return s
     }
diff --git a/front/src/components/auth/SignupForm.vue b/front/src/components/auth/SignupForm.vue
index 6374ef3a7106cde1c442ccc1c5a8ef9b6c6f23d9..a9c60f6b988e69ec58916c8f26a17650dea9b00c 100644
--- a/front/src/components/auth/SignupForm.vue
+++ b/front/src/components/auth/SignupForm.vue
@@ -117,7 +117,7 @@ export default {
         response => {
           logger.default.info("Successfully created account")
           self.$router.push({
-            name: "profile",
+            name: "profile.overview",
             params: {
               username: this.username
             }
diff --git a/front/src/components/channels/SubscribeButton.vue b/front/src/components/channels/SubscribeButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..69eef503367203d67092d6620d9c1993da2849c8
--- /dev/null
+++ b/front/src/components/channels/SubscribeButton.vue
@@ -0,0 +1,43 @@
+ <template>
+  <button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']">
+    <i class="heart icon"></i>
+    <translate v-if="isSubscribed" translate-context="Content/Track/Button.Message">Unsubscribe</translate>
+    <translate v-else translate-context="Content/Track/*/Verb">Subscribe</translate>
+  </button>
+</template>
+
+<script>
+export default {
+  props: {
+    channel: {type: Object},
+  },
+  computed: {
+    title () {
+      if (this.isSubscribed) {
+        return this.$pgettext('Content/Channel/Button/Verb', 'Subscribe')
+      } else {
+        return this.$pgettext('Content/Channel/Button/Verb', 'Unubscribe')
+      }
+    },
+    isSubscribed () {
+      return this.$store.getters['channels/isSubscribed'](this.channel.uuid)
+    }
+  },
+  methods: {
+    toggle () {
+      if (this.isSubscribed) {
+        this.$emit('unsubscribed')
+      } else {
+        this.$emit('subscribed')
+      }
+      this.$store.dispatch('channels/toggle', this.channel.uuid)
+    }
+  }
+
+
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue
index 7a3eefd33283e373a9c0695c4f7d8132ac3c0d84..04eff700a44cda523e0d17e0b458542b4a6ec3c5 100644
--- a/front/src/components/common/ActorLink.vue
+++ b/front/src/components/common/ActorLink.vue
@@ -1,12 +1,7 @@
 <template>
-  <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: actor.full_username}}" v-if="admin" :title="actor.full_username">
-    <actor-avatar v-if="avatar" :actor="actor" />
-    &nbsp;{{ actor.full_username | truncate(30) }}
+  <router-link :to="url" :title="actor.full_username">
+    <template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template>{{ repr | truncate(30) }}
   </router-link>
-  <span v-else :title="actor.full_username">
-    <actor-avatar v-if="avatar" :actor="actor" />
-    &nbsp;{{ actor.full_username | truncate(30) }}
-  </span>
 </template>
 
 <script>
@@ -17,6 +12,23 @@ export default {
     actor: {type: Object},
     avatar: {type: Boolean, default: true},
     admin: {type: Boolean, default: false},
+    displayName: {type: Boolean, default: false},
+  },
+  computed: {
+    url () {
+      if (this.actor.is_local) {
+        return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
+      } else {
+        return {name: 'profile.overview', params: {username: this.actor.full_username}}
+      }
+    },
+    repr () {
+      if (this.displayName) {
+        return this.actor.preferred_username
+      } else {
+        return this.actor.full_username
+      }
+    }
   }
 }
 </script>
diff --git a/front/src/components/common/ContentForm.vue b/front/src/components/common/ContentForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0ec2fa941320dcbaf3cad921c7aafe84c55be227
--- /dev/null
+++ b/front/src/components/common/ContentForm.vue
@@ -0,0 +1,118 @@
+<template>
+  <div class="content-form ui segments">
+    <div class="ui segment">
+      <div class="ui tiny secondary pointing menu">
+        <button @click.prevent="isPreviewing = false" :class="[{active: !isPreviewing}, 'item']">
+          <translate translate-context="*/Form/Menu.item">Write</translate>
+        </button>
+        <button @click.prevent="isPreviewing = true" :class="[{active: isPreviewing}, 'item']">
+          <translate translate-context="*/Form/Menu.item">Preview</translate>
+        </button>
+      </div>
+      <template v-if="isPreviewing" >
+
+        <div class="ui placeholder" v-if="isLoadingPreview">
+          <div class="paragraph">
+            <div class="line"></div>
+            <div class="line"></div>
+            <div class="line"></div>
+            <div class="line"></div>
+          </div>
+        </div>
+        <p v-else-if="preview === null">
+          <translate translate-context="*/Form/Paragraph">Nothing to preview.</translate>
+        </p>
+        <div v-html="preview" v-else></div>
+      </template>
+      <template v-else>
+        <div class="ui transparent input">
+          <textarea ref="textarea" :name="fieldId" :id="fieldId" rows="5" v-model="newValue" :placeholder="labels.placeholder"></textarea>
+        </div>
+        <div class="ui very small hidden divider"></div>
+      </template>
+    </div>
+    <div class="ui bottom attached segment">
+      <span :class="['right', 'floated', {'ui red text': remainingChars < 0}]">
+        {{ remainingChars }}
+      </span>
+      <p>
+        <translate translate-context="*/Form/Paragraph">Markdown syntax is supported.</translate>
+      </p>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+
+export default {
+  props: {
+    value: {type: String, default: ""},
+    fieldId: {type: String, default: "change-content"},
+    autofocus: {type: Boolean, default: false},
+  },
+  data () {
+    return {
+      isPreviewing: false,
+      preview: null,
+      newValue: this.value,
+      isLoadingPreview: false,
+      charLimit: 5000,
+    }
+  },
+  mounted () {
+    if (this.autofocus) {
+      this.$nextTick(() => {
+        this.$refs.textarea.focus()
+      })
+    }
+  },
+  methods: {
+    async loadPreview () {
+      this.isLoadingPreview = true
+      try {
+        let response = await axios.post('text-preview/', {text: this.value})
+        this.preview = response.data.rendered
+      } catch {
+
+      }
+      this.isLoadingPreview = false
+    }
+  },
+  computed: {
+    labels () {
+      return {
+        placeholder: this.$pgettext("*/Form/Placeholder", "Write a few words here…")
+      }
+    },
+    remainingChars () {
+      return this.charLimit - this.value.length
+    }
+  },
+  watch: {
+    newValue (v) {
+      this.$emit('input', v)
+    },
+    value: {
+      async handler (v) {
+        this.preview = null
+        this.newValue = v
+        if (this.isPreviewing) {
+          await this.loadPreview()
+        }
+      },
+      immediate: true,
+    },
+    async isPreviewing (v) {
+      if (v && !!this.value && this.preview === null) {
+        await this.loadPreview()
+      }
+      if (!v) {
+        this.$nextTick(() => {
+          this.$refs.textarea.focus()
+        })
+      }
+    }
+  }
+}
+</script>
diff --git a/front/src/components/common/HumanDuration.vue b/front/src/components/common/HumanDuration.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ef70ff400f0aed661667edda03f84ba8fb70f35e
--- /dev/null
+++ b/front/src/components/common/HumanDuration.vue
@@ -0,0 +1,20 @@
+<template>
+  <time :datetime="`${duration}s`">
+    <template v-if="durationObj.hours">{{ durationObj.hours|padDuration }}</template>{{ durationObj.minutes|padDuration }}:{{ durationObj.seconds|padDuration }}
+  </time>
+
+</template>
+<script>
+import {secondsToObject} from '@/filters'
+
+export default {
+  props: {
+    duration: {required: true},
+  },
+  computed: {
+    durationObj () {
+      return secondsToObject(this.duration)
+    }
+  }
+}
+</script>
diff --git a/front/src/components/common/RenderedDescription.vue b/front/src/components/common/RenderedDescription.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8d8e1db43269615c1faeaa5e310ea661e0b38962
--- /dev/null
+++ b/front/src/components/common/RenderedDescription.vue
@@ -0,0 +1,77 @@
+<template>
+  <div>
+    <div v-html="content.html" v-if="content && !isUpdating"></div>
+    <p v-else-if="!isUpdating">
+      <translate translate-context="*/*/Placeholder">No description available</translate>
+    </p>
+    <template v-if="!isUpdating && canUpdate && updateUrl">
+      <div class="ui hidden divider"></div>
+      <span role="button" @click="isUpdating = true">
+        <i class="pencil icon"></i>
+        <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
+      </span>
+    </template>
+    <form v-if="isUpdating" class="ui form" @submit.prevent="submit()">
+      <div v-if="errors.length > 0" class="ui negative message">
+        <div class="header"><translate translate-context="Content/Channels/Error message.Title">Error while updating description</translate></div>
+        <ul class="list">
+          <li v-for="error in errors">{{ error }}</li>
+        </ul>
+      </div>
+      <content-form v-model="newText" :autofocus="true"></content-form>
+      <a @click.prevent="isUpdating = false" class="left floated">
+        <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+      </a>
+      <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading">
+        <translate translate-context="Content/Channels/Button.Label/Verb">Update description</translate>
+      </button>
+      <div class="ui clearing hidden divider"></div>
+    </form>
+  </div>
+</template>
+
+<script>
+import {secondsToObject} from '@/filters'
+import axios from 'axios'
+
+export default {
+  props: {
+    content: {required: true},
+    fieldName: {required: false, default: 'description'},
+    updateUrl: {required: false, type: String},
+    canUpdate: {required: false, default: true, type: Boolean},
+  },
+  data () {
+    return {
+      isUpdating: false,
+      newText: (this.content || {text: ''}).text,
+      errors: null,
+      isLoading: false,
+      errors: [],
+    }
+  },
+  methods: {
+    submit () {
+      let self = this
+      this.isLoading = true
+      this.errors = []
+      let payload = {}
+      payload[this.fieldName] = null
+      if (this.newText) {
+        payload[this.fieldName] = {
+          content_type: "text/markdown",
+          text: this.newText,
+        }
+      }
+      axios.patch(this.updateUrl, payload).then((response) => {
+        self.$emit('updated', response.data)
+        self.isLoading = false
+        self.isUpdating = false
+      }, error => {
+        self.errors = error.backendErrors
+        self.isLoading = false
+      })
+    },
+  }
+}
+</script>
diff --git a/front/src/components/federation/LibraryWidget.vue b/front/src/components/federation/LibraryWidget.vue
index 3bdc5208937819ff97d851be32476db1bcd65522..bab61858ae57dfd862972f07679e23c027e0390d 100644
--- a/front/src/components/federation/LibraryWidget.vue
+++ b/front/src/components/federation/LibraryWidget.vue
@@ -5,10 +5,6 @@
     </h3>
     <p v-if="!isLoading && libraries.length > 0" class="ui subtitle"><slot name="subtitle"></slot></p>
     <p v-if="!isLoading && libraries.length === 0" class="ui subtitle"><translate translate-context="Content/Federation/Paragraph">No matching library.</translate></p>
-    <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'angle left', 'icon']">
-    </i>
-    <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'angle right', 'icon']">
-    </i>
     <div class="ui hidden divider"></div>
     <div class="ui cards">
       <div v-if="isLoading" class="ui inverted active dimmer">
@@ -22,6 +18,12 @@
         v-for="library in libraries"
         :key="library.uuid"></library-card>
     </div>
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
   </div>
 </template>
 
@@ -61,7 +63,7 @@ export default {
         self.previousPage = response.data.previous
         self.nextPage = response.data.next
         self.isLoading = false
-        self.libraries = response.data.results
+        self.libraries = [...self.libraries, ...response.data.results]
         self.$emit('loaded', self.libraries)
       }, error => {
         self.isLoading = false
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
index 2644324696b46570a3e60a024ba79620fc8f86f4..289fd625e5c53246c61f5f6e5798638fff557d96 100644
--- a/front/src/components/globals.js
+++ b/front/src/components/globals.js
@@ -1,6 +1,7 @@
 import Vue from 'vue'
 
 Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate"))
+Vue.component('human-duration', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDuration"))
 Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username"))
 Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
 Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"))
@@ -15,5 +16,7 @@ Vue.component('empty-state', () => import(/* webpackChunkName: "common" */ "@/co
 Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv"))
 Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
 Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback"))
+Vue.component('rendered-description', () => import(/* webpackChunkName: "common" */ "@/components/common/RenderedDescription"))
+Vue.component('content-form', () => import(/* webpackChunkName: "common" */ "@/components/common/ContentForm"))
 
 export default {}
diff --git a/front/src/components/library/ArtistBase.vue b/front/src/components/library/ArtistBase.vue
index 6d9b971ac6ba5cc016c301dac11c7188c80b9f03..d7ff13a6615f7ef66f8c9c005c81f661efbab17d 100644
--- a/front/src/components/library/ArtistBase.vue
+++ b/front/src/components/library/ArtistBase.vue
@@ -172,6 +172,18 @@ export default {
       var self = this
       this.isLoading = true
       logger.default.debug('Fetching artist "' + this.id + '"')
+
+      let artistPromise = axios.get("artists/" + this.id + "/", {params: {refresh: 'true'}}).then(response => {
+        if (response.data.channel) {
+          self.$router.replace({name: 'channels.detail', params: {id: response.data.channel.uuid}})
+        } else {
+          self.object = response.data
+        }
+      })
+      await artistPromise
+      if (!self.object) {
+        return
+      }
       let trackPromise = axios.get("tracks/", { params: { artist: this.id, hidden: '' } }).then(response => {
         self.tracks = response.data.results
         self.nextTracksUrl = response.data.next
@@ -188,13 +200,8 @@ export default {
         })
 
       })
-
-      let artistPromise = axios.get("artists/" + this.id + "/", {params: {refresh: 'true'}}).then(response => {
-        self.object = response.data
-      })
       await trackPromise
       await albumPromise
-      await artistPromise
       self.isLoadingAlbums = false
       self.isLoading = false
     }
diff --git a/front/src/components/library/ArtistDetail.vue b/front/src/components/library/ArtistDetail.vue
index 725bedac0e7c67d6b83a32a3314de41892867cc7..5b2021f98c413b09a51a431bd0267c0e3b28b3c0 100644
--- a/front/src/components/library/ArtistDetail.vue
+++ b/front/src/components/library/ArtistDetail.vue
@@ -50,7 +50,6 @@
 import _ from "@/lodash"
 import axios from "axios"
 import logger from "@/logging"
-import backend from "@/audio/backend"
 import AlbumCard from "@/components/audio/album/Card"
 import TrackTable from "@/components/audio/track/Table"
 import LibraryWidget from "@/components/federation/LibraryWidget"
diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue
index 82f3aa8a7b4c548b9cc7a38ac43f3f1f0f5684e3..ea20dc44ef67fee91ade50328ee44b0d6d65affc 100644
--- a/front/src/components/library/TrackBase.vue
+++ b/front/src/components/library/TrackBase.vue
@@ -78,7 +78,7 @@
                     <i class="external icon"></i>
                     <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
                   </a>
-		  <a :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
+		  <a v-if="discogsUrl ":href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
 		    <i class="external icon"></i>
 		    <translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
 		  </a>
@@ -200,12 +200,15 @@ export default {
       }
     },
     discogsUrl() {
-      return (
-        "https://discogs.com/search/?type=release&title=" +
-	encodeURI(this.track.album.title) + "&artist=" +
-	encodeURI(this.track.artist.name) + "&track=" +
-	encodeURI(this.track.title)
-      )
+      if (this.track.album) {
+        return (
+          "https://discogs.com/search/?type=release&title=" +
+    encodeURI(this.track.album.title) + "&artist=" +
+    encodeURI(this.track.artist.name) + "&track=" +
+    encodeURI(this.track.title)
+        )
+
+      }
     },
     downloadUrl() {
       let u = this.$store.getters["instance/absoluteUrl"](
@@ -242,8 +245,14 @@ export default {
       )
     },
     subtitle () {
-      let msg = this.$pgettext('Content/Track/Paragraph', 'From album <a class="internal" href="%{ albumUrl }">%{ album }</a> by <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
-      return this.$gettextInterpolate(msg, {album: this.track.album.title, artist: this.track.artist.name, albumUrl: this.albumUrl, artistUrl: this.artistUrl})
+      let msg
+      if (this.track.album) {
+        msg = this.$pgettext('Content/Track/Paragraph', 'From album <a class="internal" href="%{ albumUrl }">%{ album }</a> by <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
+        return this.$gettextInterpolate(msg, {album: this.track.album.title, artist: this.track.artist.name, albumUrl: this.albumUrl, artistUrl: this.artistUrl})
+      } else {
+        msg = this.$pgettext('Content/Track/Paragraph', 'By <a class="internal" href="%{ artistUrl }">%{ artist }</a>')
+        return this.$gettextInterpolate(msg, {artist: this.track.artist.name, artistUrl: this.artistUrl})
+      }
     }
   },
   watch: {
diff --git a/front/src/components/manage/library/TracksTable.vue b/front/src/components/manage/library/TracksTable.vue
index 91c07c81b66d22fd892dde3a10e7b2b366a4b3fb..4fec4c500fb84ae9ee254248e020dde13d91a529 100644
--- a/front/src/components/manage/library/TracksTable.vue
+++ b/front/src/components/manage/library/TracksTable.vue
@@ -49,10 +49,12 @@
             <router-link :to="{name: 'manage.library.tracks.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link>
           </td>
           <td>
-            <router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}">
-              <i class="wrench icon"></i>
-            </router-link>
-            <span role="button" class="discrete link" @click="addSearchToken('album_id', scope.obj.album.id)" :title="scope.obj.album.title">{{ scope.obj.album.title }}</span>
+            <template v-if="scope.obj.album">
+              <router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}">
+                <i class="wrench icon"></i>
+              </router-link>
+              <span role="button" class="discrete link" @click="addSearchToken('album_id', scope.obj.album.id)" :title="scope.obj.album.title">{{ scope.obj.album.title }}</span>
+            </template>
           </td>
           <td>
             <router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.artist.id }}">
diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue
index 29eabb996160b30918ee3fe46e25418f18628753..8746fa008adedbf98af223508bec17da4d55490d 100644
--- a/front/src/components/mixins/Report.vue
+++ b/front/src/components/mixins/Report.vue
@@ -1,7 +1,7 @@
 <script>
 export default {
   methods: {
-    getReportableObjs ({track, album, artist, playlist, account, library}) {
+    getReportableObjs ({track, album, artist, playlist, account, library, channel}) {
       let reportableObjs = []
       if (account) {
         let accountLabel = this.$pgettext('*/Moderation/*/Verb', "Report @%{ username }…")
diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue
index c56d6667aa45e4e270fcebc137add133bcfaad93..8b3d3a2a564c4aed2fc346e5993a11001ba6964d 100644
--- a/front/src/components/mixins/Translations.vue
+++ b/front/src/components/mixins/Translations.vue
@@ -49,6 +49,9 @@ export default {
               other: this.$pgettext("Content/Moderation/Dropdown", "Other"),
             },
           },
+          summary: {
+            label: this.$pgettext('Content/Account/*', 'Bio'),
+          },
         },
         filters: {
           creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue
index 2ebd9614babde74840fc2b49e9594941de424a79..59f5ea809a6424e830f9044d77dd1ea82aa8064b 100644
--- a/front/src/components/playlists/Editor.vue
+++ b/front/src/components/playlists/Editor.vue
@@ -64,7 +64,7 @@
               <tr v-for="(plt, index) in plts" :key="plt.id">
                 <td class="left aligned">{{ plt.index + 1}}</td>
                 <td class="center aligned">
-                  <img class="ui mini image" v-if="plt.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.small_square_crop)">
+                  <img class="ui mini image" v-if="plt.track.album && plt.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](plt.track.album.cover.small_square_crop)">
                   <img class="ui mini image" v-else src="../../assets/audio/default-cover.png">
                 </td>
                 <td colspan="4">
diff --git a/front/src/components/playlists/Widget.vue b/front/src/components/playlists/Widget.vue
index 3d58ef1a9b040fe76070d96098b836096af2e99e..22c607f03f233587dd4c0555c580543c75ac4afa 100644
--- a/front/src/components/playlists/Widget.vue
+++ b/front/src/components/playlists/Widget.vue
@@ -3,9 +3,6 @@
     <h3 class="ui header">
       <slot name="title"></slot>
     </h3>
-    <button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button>
-    <button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button>
-    <button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
     <div v-if="isLoading" class="ui inverted active dimmer">
       <div class="ui loader"></div>
     </div>
@@ -31,6 +28,12 @@
         </translate>
       </button>
     </div>
+    <template v-if="nextPage">
+      <div class="ui hidden divider"></div>
+      <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
+        <translate translate-context="*/*/Button,Label">Show more</translate>
+      </button>
+    </template>
   </div>
 </template>
 
@@ -50,7 +53,7 @@ export default {
   data () {
     return {
       objects: [],
-      limit: 3,
+      limit: this.filters.limit || 3,
       isLoading: false,
       errors: null,
       previousPage: null,
@@ -79,7 +82,7 @@ export default {
         self.previousPage = response.data.previous
         self.nextPage = response.data.next
         self.isLoading = false
-        self.objects = response.data.results
+        self.objects = [...self.objects, ...response.data.results]
       }, error => {
         self.isLoading = false
         self.errors = error.backendErrors
diff --git a/front/src/filters.js b/front/src/filters.js
index 9667426191e6fae0aa76ba9eba47dcad023730fe..91d4e455ca94661a3daa97daa55c423a6fa5c7c2 100644
--- a/front/src/filters.js
+++ b/front/src/filters.js
@@ -44,13 +44,22 @@ Vue.filter('ago', ago)
 export function secondsToObject (seconds) {
   let m = moment.duration(seconds, 'seconds')
   return {
+    seconds: m.seconds(),
     minutes: m.minutes(),
-    hours: parseInt(m.asHours())
+    hours: m.hours()
   }
 }
 
 Vue.filter('secondsToObject', secondsToObject)
 
+export function padDuration (duration) {
+  var s = String(duration);
+  while (s.length < 2) {s = "0" + s;}
+  return s;
+}
+
+Vue.filter('padDuration', padDuration)
+
 export function momentFormat (date, format) {
   format = format || 'lll'
   return moment(date).format(format)
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 3871f7fe7103ddb17bb6931a0eebf5fc8058fe7e..9a573b2b759c347acc27522d7c39546912131002 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -140,13 +140,33 @@ export default new Router({
         ),
       props: true
     },
-    {
-      path: "/@:username",
-      name: "profile",
-      component: () =>
-      import(/* webpackChunkName: "core" */ "@/components/auth/Profile"),
-      props: true
-    },
+    ...['/@:username', '/@:username@:domain'].map((path) => {
+      return {
+        path: path,
+        name: "profile",
+        component: () =>
+        import(/* webpackChunkName: "core" */ "@/views/auth/ProfileBase"),
+        props: true,
+        children: [
+          {
+            path: "",
+            name: "profile.overview",
+            component: () =>
+              import(
+                /* webpackChunkName: "core" */ "@/views/auth/ProfileOverview"
+              )
+          },
+          {
+            path: "activity",
+            name: "profile.activity",
+            component: () =>
+              import(
+                /* webpackChunkName: "core" */ "@/views/auth/ProfileActivity"
+              )
+          },
+        ]
+      }
+    }),
     {
       path: "/favorites",
       name: "favorites",
@@ -285,7 +305,7 @@ export default new Router({
           props: true
         },
         {
-          path: "albums",
+          path: "channels",
           name: "manage.library.albums",
           component: () =>
             import(
@@ -783,6 +803,32 @@ export default new Router({
         }
       ]
     },
+    {
+      path: "/channels/:id",
+      props: true,
+      component: () =>
+        import(
+          /* webpackChunkName: "channels" */ "@/views/channels/DetailBase"
+        ),
+      children: [
+        {
+          path: "",
+          name: "channels.detail",
+          component: () =>
+            import(
+              /* webpackChunkName: "channels" */ "@/views/channels/DetailOverview"
+            )
+        },
+        {
+          path: "episodes",
+          name: "channels.detail.episodes",
+          component: () =>
+            import(
+              /* webpackChunkName: "channels" */ "@/views/channels/DetailEpisodes"
+            )
+        },
+      ]
+    },
     {
       path: "*/index.html",
       redirect: "/"
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index 93ba3b4318d2d708d8560bc9a1a3edd9701232db..a87d8ba7820ca35fb74c5f7ae7799a9615bfb39b 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -140,6 +140,7 @@ export default {
             dispatch('ui/fetchPendingReviewReports', null, { root: true })
           }
           dispatch('favorites/fetch', null, { root: true })
+          dispatch('channels/fetchSubscriptions', null, { root: true })
           dispatch('moderation/fetchContentFilters', null, { root: true })
           dispatch('playlists/fetchOwn', null, { root: true })
         }, (response) => {
diff --git a/front/src/store/channels.js b/front/src/store/channels.js
new file mode 100644
index 0000000000000000000000000000000000000000..0f2c8b23c0f9898fa900320201e2079164e6fa31
--- /dev/null
+++ b/front/src/store/channels.js
@@ -0,0 +1,65 @@
+import axios from 'axios'
+import logger from '@/logging'
+
+export default {
+  namespaced: true,
+  state: {
+    subscriptions: [],
+    count: 0
+  },
+  mutations: {
+    subscriptions: (state, {uuid, value}) => {
+      if (value) {
+        if (state.subscriptions.indexOf(uuid) === -1) {
+          state.subscriptions.push(uuid)
+        }
+      } else {
+        let i = state.subscriptions.indexOf(uuid)
+        if (i > -1) {
+          state.subscriptions.splice(i, 1)
+        }
+      }
+      state.count = state.subscriptions.length
+    },
+    reset (state) {
+      state.subscriptions = []
+      state.count = 0
+    }
+  },
+  getters: {
+    isSubscribed: (state) => (uuid) => {
+      return state.subscriptions.indexOf(uuid) > -1
+    }
+  },
+  actions: {
+    set ({commit, state}, {uuid, value}) {
+      commit('subscriptions', {uuid, value})
+      if (value) {
+        return axios.post(`channels/${uuid}/subscribe/`).then((response) => {
+          logger.default.info('Successfully subscribed to channel')
+        }, (response) => {
+          logger.default.info('Error while subscribing to channel')
+          commit('subscriptions', {uuid, value: !value})
+        })
+      } else {
+        return axios.post(`channels/${uuid}/unsubscribe/`).then((response) => {
+          logger.default.info('Successfully unsubscribed from channel')
+        }, (response) => {
+          logger.default.info('Error while unsubscribing from channel')
+          commit('subscriptions', {uuid, value: !value})
+        })
+      }
+    },
+    toggle ({getters, dispatch}, uuid) {
+      dispatch('set', {uuid, value: !getters['isSubscribed'](uuid)})
+    },
+    fetchSubscriptions ({dispatch, state, commit, rootState}, url) {
+      let promise = axios.get('subscriptions/all/')
+      return promise.then((response) => {
+        response.data.results.forEach(result => {
+          commit('subscriptions', {uuid: result.channel, value: true})
+        })
+      })
+    }
+  }
+}
diff --git a/front/src/store/index.js b/front/src/store/index.js
index e10f97b51afea73078d845a82871284cbf48731f..c098aa1ec5436ff88e867a7e359efed0a8f21336 100644
--- a/front/src/store/index.js
+++ b/front/src/store/index.js
@@ -3,6 +3,7 @@ import Vuex from 'vuex'
 import createPersistedState from 'vuex-persistedstate'
 
 import favorites from './favorites'
+import channels from './channels'
 import auth from './auth'
 import instance from './instance'
 import moderation from './moderation'
@@ -18,6 +19,7 @@ export default new Vuex.Store({
   modules: {
     ui,
     auth,
+    channels,
     favorites,
     instance,
     moderation,
@@ -76,21 +78,24 @@ export default new Vuex.Store({
                 mbid: track.artist.mbid,
                 name: track.artist.name
               }
-              return {
+              let data = {
                 id: track.id,
                 title: track.title,
                 mbid: track.mbid,
                 uploads: track.uploads,
                 listen_url: track.listen_url,
-                album: {
+                artist: artist,
+              }
+              if (track.album) {
+                data.album = {
                   id: track.album.id,
                   title: track.album.title,
                   mbid: track.album.mbid,
                   cover: track.album.cover,
                   artist: artist
-                },
-                artist: artist
+                }
               }
+              return data
             })
           }
         }
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index 8fa74afb4c558a73b2e92a097ec186c1993ec77d..b98dc54a65a699227f5b33276a5c8ca7c4fc6788 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -112,6 +112,12 @@ export default {
 
       let instanceUrl = state.instanceUrl || getDefaultUrl()
       return instanceUrl + relativeUrl
+    },
+    domain: (state) => {
+      let url = state.instanceUrl
+      let parser = document.createElement("a")
+      parser.href = url
+      return parser.hostname
     }
   },
   actions: {
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index c48bb0dc8481eba9066340ded79411cadfbd4dcc..3ffea35286c322ba6916310fc2ee2b44358ef23c 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -69,6 +69,7 @@
 @import "~fomantic-ui-css/components/sidebar.css";
 @import "~fomantic-ui-css/components/sticky.css";
 @import "~fomantic-ui-css/components/tab.css";
+@import "~fomantic-ui-css/components/text.css";
 @import "~fomantic-ui-css/components/transition.css";
 
 
@@ -194,6 +195,16 @@ html {
   }
 }
 
+.stripe.segment > .secondary.menu:last-child {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  border-bottom: none;
+}
+.center.aligned.menu {
+  justify-content: center;
+}
 .ellipsis:not(.icon) {
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -387,6 +398,9 @@ input + .help {
 .ui.small.divider {
   margin: 0.5rem 0;
 }
+.ui.very.small.divider {
+  margin: 0.25rem 0;
+}
 
 .queue.segment.player-focused #queue-grid #player {
   @include media("<desktop") {
@@ -431,6 +445,12 @@ input + .help {
     display: flex;
     width: $card-width;
     height: $card-hight;
+    .content:not(.extra) {
+      padding: 0.5em 1em 0;
+    }
+    .content.extra {
+      padding: 0.5em 1em;
+    }
     .head-image {
       height: $card-width;
       background-size: cover !important;
@@ -449,6 +469,10 @@ input + .help {
         margin: 0.5em;
 
       }
+      &.padded {
+        margin: 0.5em;
+        border-radius: 0.25em !important;
+      }
       &.squares {
         display: block !important;
         position: relative;
@@ -490,5 +514,58 @@ input + .help {
     }
   }
 }
+
+//  channels stuff
+
+.channel-entry-card, .channel-serie-card {
+  display: flex;
+  width: 100%;
+  align-items: center;
+  margin: 0 auto 1em;
+  justify-content: space-between;
+  .image {
+    width: 3.5em;
+    margin-right: 1em;
+  }
+  .two-images {
+    width: 3.5em;
+    height: 3.5em;
+    margin-right: 1em;
+    position: relative;
+    img {
+      width: 2.5em;
+      position: absolute;
+      &:last-child {
+        bottom: 0;
+        left: 0;
+      }
+      &:first-child {
+        top: 0;
+        right: 0;
+      }
+    }
+  }
+  .content {
+    flex-grow: 1;
+  }
+}
+.channel-image {
+  border: 1px solid rgba(0, 0, 0, 0.5);
+  border-radius: 0.3em;
+  &.large {
+    width: 8em !important;
+  }
+}
+.content-form {
+  .segment:first-child {
+    min-height: 15em;
+  }
+  .ui.secondary.menu {
+    margin-top: -0.5em;
+  }
+  .input {
+    width: 100%;
+  }
+}
 @import "./themes/_light.scss";
 @import "./themes/_dark.scss";
diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue
index 54223543ac4832e6bab9c08ed82232a3d9ca5a74..f01457b1246da444c79a1b16f065ad50ff488d28 100644
--- a/front/src/views/admin/library/TrackDetail.vue
+++ b/front/src/views/admin/library/TrackDetail.vue
@@ -77,7 +77,7 @@
                     :class="['ui', {loading: isLoading}, 'basic button']"
                     :action="remove">
                     <translate translate-context="*/*/*/Verb">Delete</translate>
-                    <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p>
+                    <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this track?</translate></p>
                     <div slot="modal-content">
                       <p><translate translate-context="Content/Moderation/Paragraph">The track will be removed, as well as associated uploads, favorites and listening history. This action is irreversible.</translate></p>
                     </div>
@@ -109,7 +109,7 @@
                       {{ object.title }}
                     </td>
                   </tr>
-                  <tr>
+                  <tr v-if="object.album">
                     <td>
                       <router-link :to="{name: 'manage.library.albums.detail', params: {id: object.album.id }}">
                         <translate translate-context="*/*/*">Album</translate>
@@ -130,7 +130,7 @@
                       {{ object.artist.name }}
                     </td>
                   </tr>
-                  <tr>
+                  <tr v-if="object.album">
                     <td>
                       <router-link :to="{name: 'manage.library.artists.detail', params: {id: object.album.artist.id }}">
                         <translate translate-context="*/*/*/Noun">Album artist</translate>
diff --git a/front/src/views/auth/ProfileActivity.vue b/front/src/views/auth/ProfileActivity.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1db7ea2a0fa807814110b46f1e18906110d208e8
--- /dev/null
+++ b/front/src/views/auth/ProfileActivity.vue
@@ -0,0 +1,34 @@
+<template>
+  <section class="ui stackable three column grid">
+    <div class="column">
+      <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">
+      <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">
+      <h2 class="ui header">
+        <translate translate-context="*/*/*">Playlists</translate>
+      </h2>
+      <playlist-widget :url="'playlists/'" :filters="{scope: `actor:${object.full_username}`, playable: true, ordering: '-modification_date'}">
+      </playlist-widget>
+    </div>
+  </section>
+</template>
+
+<script>
+import TrackWidget from "@/components/audio/track/Widget"
+import PlaylistWidget from "@/components/playlists/Widget"
+
+export default {
+  props: ['object'],
+  components: {TrackWidget, PlaylistWidget},
+}
+</script>
diff --git a/front/src/views/auth/ProfileBase.vue b/front/src/views/auth/ProfileBase.vue
new file mode 100644
index 0000000000000000000000000000000000000000..927446649304225179b835f91786bb926aa6eb75
--- /dev/null
+++ b/front/src/views/auth/ProfileBase.vue
@@ -0,0 +1,130 @@
+<template>
+  <main class="main pusher" v-title="labels.usernameProfile">
+    <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="divider"></div>
+          <router-link class="basic item" v-if="$store.state.auth.availablePermissions['moderation']" :to="{name: 'manage.moderation.accounts.detail', params: {id: object.full_username}}">
+            <i class="wrench icon"></i>
+            <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
+          </router-link>
+        </div>
+      </div>
+      <div class="ui head vertical stripe segment">
+        <h1 class="ui center aligned icon header">
+          <i v-if="!object.icon" class="circular inverted user green icon"></i>
+          <img class="ui big circular image" v-else v-lazy="$store.getters['instance/absoluteUrl'](object.icon.square_crop)" />
+          <div class="ellispsis content">
+            <div class="ui very small hidden divider"></div>
+            <span :title="displayName">{{ displayName }}</span>
+            <div class="ui very small hidden divider"></div>
+            <span class="ui grey tiny text" :title="object.full_username">{{ object.full_username }}</span>
+          </div>
+          <template  v-if="object.full_username === $store.state.auth.fullUsername">
+            <div class="ui very small hidden divider"></div>
+            <div class="ui basic green label">
+              <translate translate-context="Content/Profile/Button.Paragraph">This is you!</translate>
+            </div>
+          </template>
+        </h1>
+        <div class="ui container">
+          <div class="ui secondary pointing center aligned menu">
+            <router-link class="item" :exact="true" :to="{name: 'profile.overview', params: routerParams}">
+              <translate translate-context="Content/Profile/Link">Overview</translate>
+            </router-link>
+            <router-link class="item" :exact="true" :to="{name: 'profile.activity', params: routerParams}">
+              <translate translate-context="Content/Profile/*">Activity</translate>
+            </router-link>
+          </div>
+          <div class="ui hidden divider"></div>
+          <keep-alive>
+            <router-view @updated="fetch" :object="object"></router-view>
+          </keep-alive>
+
+        </div>
+      </div>
+    </template>
+  </main>
+</template>
+
+<script>
+import { mapState } from "vuex"
+import axios from 'axios'
+
+import ReportMixin from '@/components/mixins/Report'
+
+export default {
+  mixins: [ReportMixin],
+  props: {
+    username: {type: String, required: true},
+    domain: {type: String, required: false, default: null},
+  },
+  data () {
+    return {
+      object: null,
+      isLoading: false,
+    }
+  },
+  created() {
+    this.fetch()
+  },
+  methods: {
+    fetch () {
+      let self = this
+      self.isLoading = true
+      axios.get(`federation/actors/${this.fullUsername}/`).then((response) => {
+        self.object = response.data
+        self.isLoading = false
+      })
+    }
+  },
+  computed: {
+    labels() {
+      let msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile")
+      let usernameProfile = this.$gettextInterpolate(msg, {
+        username: this.username
+      })
+      return {
+        usernameProfile
+      }
+    },
+    fullUsername () {
+      if (this.username && this.domain) {
+        return `${this.username}@${this.domain}`
+      } else {
+        return `${this.username}@${this.$store.getters['instance/domain']}`
+      }
+    },
+    routerParams () {
+      if (this.domain) {
+        return {username: this.username, domain: this.domain}
+      } else {
+        return {username: this.username}
+      }
+    },
+    displayName () {
+      return this.object.name || this.object.preferred_username
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+.ui.header > img.image {
+  width: 8em;
+}
+</style>
diff --git a/front/src/views/auth/ProfileOverview.vue b/front/src/views/auth/ProfileOverview.vue
new file mode 100644
index 0000000000000000000000000000000000000000..cf65e42b9fd230d68a99f0b6e09b70ecc5db5d4f
--- /dev/null
+++ b/front/src/views/auth/ProfileOverview.vue
@@ -0,0 +1,35 @@
+<template>
+  <section class="ui stackable grid">
+    <div class="six wide column">
+      <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="ten wide column">
+      <h2 class="ui header">
+        <translate translate-context="*/*/*">Channels</translate>
+      </h2>
+      <channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
+      <h2 class="ui header">
+        <translate translate-context="Content/Profile/Header">User Libraries</translate>
+      </h2>
+      <library-widget :url="`federation/actors/${object.full_username}/libraries/`">
+        <translate translate-context="Content/Profile/Paragraph" slot="subtitle">This user shared the following libraries.</translate>
+      </library-widget>
+    </div>
+  </section>
+</template>
+
+<script>
+import LibraryWidget from "@/components/federation/LibraryWidget"
+import ChannelsWidget from "@/components/audio/ChannelsWidget"
+
+export default {
+  props: ['object'],
+  components: {ChannelsWidget, LibraryWidget},
+}
+</script>
diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e61aab28ed6c54642a6818812a118726cf3c9bfe
--- /dev/null
+++ b/front/src/views/channels/DetailBase.vue
@@ -0,0 +1,203 @@
+<template>
+  <main class="main pusher" v-title="labels.title">
+    <div v-if="isLoading" class="ui vertical segment">
+      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+    </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 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)">
+                <i v-else class="huge circular inverted users violet icon"></i>
+              </div>
+              <div class="ui column right aligned">
+                <tags-list v-if="object.artist.tags && object.artist.tags.length > 0" :tags="object.artist.tags"></tags-list>
+                <actor-link :avatar="false" :actor="object.attributed_to" :display-name="true"></actor-link>
+                <template v-if="totalTracks > 0">
+                  <div class="ui hidden very small divider"></div>
+                  <translate translate-context="Content/Channel/Paragraph"
+                    translate-plural="%{ count } episodes"
+                    :translate-n="totalTracks"
+                    :translate-params="{count: totalTracks}">
+                    %{ count } episode
+                  </translate>
+                  <template v-if="object.attributed_to.full_username === $store.state.auth.fullUsername || $store.getters['channels/isSubscribed'](object.uuid)">
+                    · <translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" :translate-n="object.subscriptions_count" :translate-params="{count: object.subscriptions_count}">%{ count } subscriber</translate>
+                  </template>
+                </template>
+                <div class="ui hidden small divider"></div>
+                <a :href="rssUrl" target="_blank" class="ui icon small basic button">
+                  <i class="feed icon"></i>
+                </a>
+                <div class="ui dropdown icon small basic button" ref="dropdown" v-dropdown>
+                  <i class="ellipsis vertical icon"></i>
+                  <div class="menu">
+                    <div
+                      role="button"
+                      v-if="totalTracks > 0"
+                      @click="showEmbedModal = !showEmbedModal"
+                      class="basic item">
+                      <i class="code icon"></i>
+                      <translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
+                    </div>
+                    <div class="divider"></div>
+                    <div
+                      role="button"
+                      class="basic item"
+                      v-for="obj in getReportableObjs({channel: 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['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>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <h1 class="ui header">
+              <div class="left aligned content ellipsis">
+                {{ object.artist.name }}
+                <div class="ui hidden very small divider"></div>
+                <div class="sub header">
+                  {{ object.actor.full_username }}
+                </div>
+              </div>
+            </h1>
+            <div class="header-buttons">
+              <div class="ui buttons">
+                <play-button :is-playable="isPlayable" class="orange" :channel="object">
+                  <translate translate-context="Content/Channels/Button.Label/Verb">Play</translate>
+                </play-button>
+              </div>
+              <div class="ui buttons">
+                <subscribe-button @subscribed="object.subscriptions_count += 1" @unsubscribed="object.subscriptions_count -= 1" :channel="object"></subscribe-button>
+              </div>
+
+              <modal :show.sync="showEmbedModal" v-if="totalTracks > 0">
+                <div class="header">
+                  <translate translate-context="Popup/Artist/Title/Verb">Embed this artist work on your website</translate>
+                </div>
+                <div class="content">
+                  <div class="description">
+                    <embed-wizard type="artist" :id="object.artist.id" />
+                  </div>
+                </div>
+                <div class="actions">
+                  <div class="ui deny button">
+                    <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
+                  </div>
+                </div>
+              </modal>
+            </div>
+            <div>
+              <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>
+            </div>
+          </div>
+          <div class="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>
+              </router-link>
+              <router-link class="item" :exact="true" :to="{name: 'channels.detail.episodes', params: {id: id}}">
+                <translate translate-context="Content/Channels/*">Episodes</translate>
+              </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>
+          </div>
+        </div>
+      </section>
+    </template>
+  </main>
+</template>
+
+<script>
+import axios from "axios"
+import PlayButton from "@/components/audio/PlayButton"
+import ChannelEntries from "@/components/audio/ChannelEntries"
+import ChannelSeries from "@/components/audio/ChannelSeries"
+import EmbedWizard from "@/components/audio/EmbedWizard"
+import Modal from '@/components/semantic/Modal'
+import TagsList from "@/components/tags/List"
+import ReportMixin from '@/components/mixins/Report'
+
+import SubscribeButton from '@/components/channels/SubscribeButton'
+
+export default {
+  mixins: [ReportMixin],
+  props: ["id"],
+  components: {
+    PlayButton,
+    EmbedWizard,
+    Modal,
+    TagsList,
+    ChannelEntries,
+    ChannelSeries,
+    SubscribeButton
+  },
+  data() {
+    return {
+      isLoading: true,
+      object: null,
+      totalTracks: 0,
+      latestTracks: null,
+      showEmbedModal: false,
+    }
+  },
+  async created() {
+    await this.fetchData()
+  },
+  methods: {
+    async fetchData() {
+      var self = this
+      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
+      })
+      await channelPromise
+      await tracksPromise
+      self.isLoading = false
+    }
+  },
+  computed: {
+    labels() {
+      return {
+        title: this.$pgettext('*/*/*', 'Channel')
+      }
+    },
+    contentFilter () {
+      let self = this
+      return this.$store.getters['moderation/artistFilters']().filter((e) => {
+        return e.target.id === this.object.artist.id
+      })[0]
+    },
+    isPlayable () {
+      return this.totalTracks > 0
+    },
+    rssUrl () {
+      return this.$store.getters['instance/absoluteUrl'](`api/v1/channels/${this.id}/rss`)
+    }
+  },
+  watch: {
+    id() {
+      this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/views/channels/DetailEpisodes.vue b/front/src/views/channels/DetailEpisodes.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ab5920eb462ca520122d7f6f301c7e82120267ae
--- /dev/null
+++ b/front/src/views/channels/DetailEpisodes.vue
@@ -0,0 +1,18 @@
+<template>
+  <section>
+    <channel-entries :limit="25" :filters="{channel: object.uuid, ordering: '-creation_date', playable: 'true'}">
+    </channel-entries>
+  </section>
+</template>
+
+<script>
+import ChannelEntries from "@/components/audio/ChannelEntries"
+
+
+export default {
+  props: ['object'],
+  components: {
+    ChannelEntries,
+  },
+}
+</script>
diff --git a/front/src/views/channels/DetailOverview.vue b/front/src/views/channels/DetailOverview.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9596e28d4d3a18c2d37e1d81e990f1fe06938865
--- /dev/null
+++ b/front/src/views/channels/DetailOverview.vue
@@ -0,0 +1,29 @@
+<template>
+  <section>
+    <channel-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">
+        <translate translate-context="Content/Channel/Paragraph">Series</translate>
+      </h2>
+    </channel-series>
+  </section>
+</template>
+
+<script>
+import ChannelEntries from "@/components/audio/ChannelEntries"
+import ChannelSeries from "@/components/audio/ChannelSeries"
+
+
+export default {
+  props: ['object'],
+  components: {
+    ChannelEntries,
+    ChannelSeries,
+  },
+}
+</script>
diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue
index 736421e57ef44bb0916cbe68187d954aeb91a498..a00442d1a73974adfe0bf0c35548ee0b0cc4a75c 100644
--- a/front/src/views/content/libraries/FilesTable.vue
+++ b/front/src/views/content/libraries/FilesTable.vue
@@ -133,6 +133,7 @@
             </td>
             <td>
               <span
+                v-if="scope.obj.track.album"
                 class="discrete link"
                 @click="addSearchToken('album', scope.obj.track.album.title)"
                 :title="scope.obj.track.album.title"