diff --git a/api/config/spa_urls.py b/api/config/spa_urls.py
index 730fcc298c7225e1ed7c2b55afff1aa152864a8d..8c9fe51494418b848d8b4eb513a8c6829089cfaf 100644
--- a/api/config/spa_urls.py
+++ b/api/config/spa_urls.py
@@ -21,6 +21,11 @@ urlpatterns = [
         spa_views.library_playlist,
         name="library_playlist",
     ),
+    urls.re_path(
+        r"^library/(?P<uuid>[0-9a-f-]+)/?$",
+        spa_views.library_library,
+        name="library_library",
+    ),
     urls.re_path(
         r"^channels/(?P<uuid>[0-9a-f-]+)/?$",
         audio_spa_views.channel_detail_uuid,
diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py
index db06e31978a59053163d136c59ab64dbe713fdff..39e6e584a6adba955388df835f0da200ef15fcd2 100644
--- a/api/funkwhale_api/federation/api_views.py
+++ b/api/funkwhale_api/federation/api_views.py
@@ -98,6 +98,26 @@ class LibraryFollowViewSet(
         update_follow(follow, approved=False)
         return response.Response(status=204)
 
+    @decorators.action(methods=["get"], detail=False)
+    def all(self, request, *args, **kwargs):
+        """
+        Return all the subscriptions of the current user, with only limited data
+        to have a performant endpoint and avoid lots of queries just to display
+        subscription status in the UI
+        """
+        follows = list(
+            self.get_queryset().values_list("uuid", "target__uuid", "approved")
+        )
+
+        payload = {
+            "results": [
+                {"uuid": str(u[0]), "library": str(u[1]), "approved": u[2]}
+                for u in follows
+            ],
+            "count": len(follows),
+        }
+        return response.Response(payload, status=200)
+
 
 class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
     lookup_field = "uuid"
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
index cab3baf6df9a87bebb15658cf96a8371fe0b6103..8070b1310306acf04d2902e9aa6363b5895d66a9 100644
--- a/api/funkwhale_api/federation/utils.py
+++ b/api/funkwhale_api/federation/utils.py
@@ -201,3 +201,26 @@ def find_alternate(response_text):
         parser.feed(response_text)
     except StopParsing:
         return parser.result
+
+
+def should_redirect_ap_to_html(accept_header):
+    if not accept_header:
+        return False
+
+    redirect_headers = [
+        "text/html",
+    ]
+    no_redirect_headers = [
+        "application/json",
+        "application/activity+json",
+        "application/ld+json",
+    ]
+
+    parsed_header = [ct.lower().strip() for ct in accept_header.split(",")]
+    for ct in parsed_header:
+        if ct in redirect_headers:
+            return True
+        if ct in no_redirect_headers:
+            return False
+
+    return True
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index c20d792bc547069e24569d73cf186849b256859c..5efebf1ecf2871d2d188b1a175c77686bcaac6fb 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -1,4 +1,5 @@
 from django import forms
+from django.conf import settings
 from django.core import paginator
 from django.db.models import Prefetch
 from django.http import HttpResponse
@@ -7,6 +8,7 @@ from rest_framework import exceptions, mixins, permissions, response, viewsets
 from rest_framework.decorators import action
 
 from funkwhale_api.common import preferences
+from funkwhale_api.common import utils as common_utils
 from funkwhale_api.moderation import models as moderation_models
 from funkwhale_api.music import models as music_models
 from funkwhale_api.music import utils as music_utils
@@ -14,6 +16,12 @@ from funkwhale_api.music import utils as music_utils
 from . import activity, authentication, models, renderers, serializers, utils, webfinger
 
 
+def redirect_to_html(public_url):
+    response = HttpResponse(status=302)
+    response["Location"] = common_utils.join_url(settings.FUNKWHALE_URL, public_url)
+    return response
+
+
 class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
     def has_permission(self, request, view):
         allow_list_enabled = preferences.get("moderation__allow_list_enabled")
@@ -204,13 +212,18 @@ class MusicLibraryViewSet(
     renderer_classes = renderers.get_ap_renderers()
     serializer_class = serializers.LibrarySerializer
     queryset = (
-        music_models.Library.objects.all().select_related("actor").filter(channel=None)
+        music_models.Library.objects.all()
+        .local()
+        .select_related("actor")
+        .filter(channel=None)
     )
     lookup_field = "uuid"
 
     def retrieve(self, request, *args, **kwargs):
         lb = self.get_object()
-
+        if utils.should_redirect_ap_to_html(request.headers.get("accept")):
+            # XXX: implement this for actors, albums, tracks, artists
+            return redirect_to_html(lb.get_absolute_url())
         conf = {
             "id": lb.get_federation_id(),
             "actor": lb.actor,
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index 2395a8381442f16a2f7c8eaf9459c65d47633cfe..60a9e5f865968f7d14b2719fe3b2ee29810dd5b9 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -41,8 +41,30 @@ class ChannelFilterSet(filters.FilterSet):
         return queryset.filter(pk__in=ids)
 
 
+class LibraryFilterSet(filters.FilterSet):
+
+    library = filters.CharFilter(field_name="_", method="filter_library")
+
+    def filter_library(self, queryset, name, value):
+        if not value:
+            return queryset
+
+        actor = utils.get_actor_from_request(self.request)
+        library = models.Library.objects.filter(uuid=value).viewable_by(actor).first()
+
+        if not library:
+            return queryset.none()
+
+        uploads = models.Upload.objects.filter(library=library)
+        uploads = uploads.playable_by(actor)
+        ids = uploads.values_list(self.Meta.library_filter_field, flat=True)
+        return queryset.filter(pk__in=ids)
+
+
 class ArtistFilter(
-    audio_filters.IncludeChannelsFilterSet, moderation_filters.HiddenContentFilterSet
+    LibraryFilterSet,
+    audio_filters.IncludeChannelsFilterSet,
+    moderation_filters.HiddenContentFilterSet,
 ):
 
     q = fields.SearchFilter(search_fields=["name"], fts_search_fields=["body_text"])
@@ -62,6 +84,7 @@ class ArtistFilter(
         }
         hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
         include_channels_field = "channel"
+        library_filter_field = "track__artist"
 
     def filter_playable(self, queryset, name, value):
         actor = utils.get_actor_from_request(self.request)
@@ -70,6 +93,7 @@ class ArtistFilter(
 
 class TrackFilter(
     ChannelFilterSet,
+    LibraryFilterSet,
     audio_filters.IncludeChannelsFilterSet,
     moderation_filters.HiddenContentFilterSet,
 ):
@@ -99,6 +123,7 @@ class TrackFilter(
         hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
         include_channels_field = "artist__channel"
         channel_filter_field = "track"
+        library_filter_field = "track"
 
     def filter_playable(self, queryset, name, value):
         actor = utils.get_actor_from_request(self.request)
@@ -156,6 +181,7 @@ class UploadFilter(audio_filters.IncludeChannelsFilterSet):
 
 class AlbumFilter(
     ChannelFilterSet,
+    LibraryFilterSet,
     audio_filters.IncludeChannelsFilterSet,
     moderation_filters.HiddenContentFilterSet,
 ):
@@ -175,6 +201,7 @@ class AlbumFilter(
         hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
         include_channels_field = "artist__channel"
         channel_filter_field = "track__album"
+        library_filter_field = "track__album"
 
     def filter_playable(self, queryset, name, value):
         actor = utils.get_actor_from_request(self.request)
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index f27ed3d501ef2c6ef4a0ed8014642aa447011ea0..e0adfe86b480de3c0e340923e171fe03223bcd4f 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -1110,6 +1110,12 @@ LIBRARY_PRIVACY_LEVEL_CHOICES = [
 
 
 class LibraryQuerySet(models.QuerySet):
+    def local(self, include=True):
+        query = models.Q(actor__domain_id=settings.FEDERATION_HOSTNAME)
+        if not include:
+            query = ~query
+        return self.filter(query)
+
     def with_follows(self, actor):
         return self.prefetch_related(
             models.Prefetch(
@@ -1123,14 +1129,14 @@ class LibraryQuerySet(models.QuerySet):
         from funkwhale_api.federation.models import LibraryFollow
 
         if actor is None:
-            return Library.objects.filter(privacy_level="everyone")
+            return self.filter(privacy_level="everyone")
 
         me_query = models.Q(privacy_level="me", actor=actor)
         instance_query = models.Q(privacy_level="instance", actor__domain=actor.domain)
         followed_libraries = LibraryFollow.objects.filter(
             actor=actor, approved=True
         ).values_list("target", flat=True)
-        return Library.objects.filter(
+        return self.filter(
             me_query
             | instance_query
             | models.Q(privacy_level="everyone")
@@ -1164,6 +1170,9 @@ class Library(federation_models.FederationMixin):
             reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
         )
 
+    def get_absolute_url(self):
+        return "/library/{}".format(self.uuid)
+
     def save(self, **kwargs):
         if not self.pk and not self.fid and self.actor.get_user():
             self.fid = self.get_federation_id()
diff --git a/api/funkwhale_api/music/spa_views.py b/api/funkwhale_api/music/spa_views.py
index 0619166a664d7d028cce756c959f1d5fb48cfa54..073f5bb965d4391930d5358327fc80d68a02c77b 100644
--- a/api/funkwhale_api/music/spa_views.py
+++ b/api/funkwhale_api/music/spa_views.py
@@ -292,3 +292,33 @@ def library_playlist(request, pk):
         # twitter player is also supported in various software
         metas += get_twitter_card_metas(type="playlist", id=obj.pk)
     return metas
+
+
+def library_library(request, uuid):
+    queryset = models.Library.objects.filter(uuid=uuid)
+    try:
+        obj = queryset.get()
+    except models.Library.DoesNotExist:
+        return []
+    library_url = utils.join_url(
+        settings.FUNKWHALE_URL,
+        utils.spa_reverse("library_library", kwargs={"uuid": obj.uuid}),
+    )
+    metas = [
+        {"tag": "meta", "property": "og:url", "content": library_url},
+        {"tag": "meta", "property": "og:type", "content": "website"},
+        {"tag": "meta", "property": "og:title", "content": obj.name},
+        {"tag": "meta", "property": "og:description", "content": obj.description},
+    ]
+
+    if preferences.get("federation__enabled"):
+        metas.append(
+            {
+                "tag": "link",
+                "rel": "alternate",
+                "type": "application/activity+json",
+                "href": obj.fid,
+            }
+        )
+
+    return metas
diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py
index ab74689bf07c6a3fce1c7f460d56f539afd4e450..e3f76e849c4eb7758899a6430df5081ebb09e035 100644
--- a/api/tests/federation/test_api_views.py
+++ b/api/tests/federation/test_api_views.py
@@ -286,3 +286,25 @@ def test_fetch_duplicate_bypass_with_force(
     assert response.status_code == 201
     assert response.data == api_serializers.FetchSerializer(fetch).data
     fetch_task.assert_called_once_with(fetch_id=fetch.pk)
+
+
+def test_library_follow_get_all(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    library = factories["music.Library"]()
+    follow = factories["federation.LibraryFollow"](target=library, actor=actor)
+    factories["federation.LibraryFollow"]()
+    factories["music.Library"]()
+    url = reverse("api:v1:federation:library-follows-all")
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == {
+        "results": [
+            {
+                "uuid": str(follow.uuid),
+                "library": str(library.uuid),
+                "approved": follow.approved,
+            }
+        ],
+        "count": 1,
+    }
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 800b737897567c76db443ab45198121e6ae4b76c..0b5759937445be16c6547cf055c641368d926c48 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -2,7 +2,14 @@ import pytest
 from django.core.paginator import Paginator
 from django.urls import reverse
 
-from funkwhale_api.federation import actors, serializers, webfinger
+from funkwhale_api.common import utils
+
+from funkwhale_api.federation import (
+    actors,
+    serializers,
+    webfinger,
+    utils as federation_utils,
+)
 
 
 def test_authenticate_skips_anonymous_fetch_when_allow_list_enabled(
@@ -159,7 +166,7 @@ def test_wellknown_webfinger_local(factories, api_client, settings, mocker):
 
 @pytest.mark.parametrize("privacy_level", ["me", "instance", "everyone"])
 def test_music_library_retrieve(factories, api_client, privacy_level):
-    library = factories["music.Library"](privacy_level=privacy_level)
+    library = factories["music.Library"](privacy_level=privacy_level, actor__local=True)
     expected = serializers.LibrarySerializer(library).data
 
     url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
@@ -170,7 +177,7 @@ def test_music_library_retrieve(factories, api_client, privacy_level):
 
 
 def test_music_library_retrieve_excludes_channel_libraries(factories, api_client):
-    channel = factories["audio.Channel"]()
+    channel = factories["audio.Channel"](local=True)
     library = channel.library
 
     url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
@@ -180,7 +187,7 @@ def test_music_library_retrieve_excludes_channel_libraries(factories, api_client
 
 
 def test_music_library_retrieve_page_public(factories, api_client):
-    library = factories["music.Library"](privacy_level="everyone")
+    library = factories["music.Library"](privacy_level="everyone", actor__local=True)
     upload = factories["music.Upload"](library=library, import_status="finished")
     id = library.get_federation_id()
     expected = serializers.CollectionPageSerializer(
@@ -253,7 +260,7 @@ def test_channel_upload_retrieve(factories, api_client):
 
 @pytest.mark.parametrize("privacy_level", ["me", "instance"])
 def test_music_library_retrieve_page_private(factories, api_client, privacy_level):
-    library = factories["music.Library"](privacy_level=privacy_level)
+    library = factories["music.Library"](privacy_level=privacy_level, actor__local=True)
     url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
     response = api_client.get(url, {"page": 1})
 
@@ -264,7 +271,7 @@ def test_music_library_retrieve_page_private(factories, api_client, privacy_leve
 def test_music_library_retrieve_page_follow(
     factories, api_client, authenticated_actor, approved, expected
 ):
-    library = factories["music.Library"](privacy_level="me")
+    library = factories["music.Library"](privacy_level="me", actor__local=True)
     factories["federation.LibraryFollow"](
         actor=authenticated_actor, target=library, approved=approved
     )
@@ -344,3 +351,35 @@ def test_music_upload_detail_private_approved_follow(
     response = api_client.get(url)
 
     assert response.status_code == 200
+
+
+@pytest.mark.parametrize(
+    "accept_header,expected",
+    [
+        ("text/html,application/xhtml+xml", True),
+        ("text/html,application/json", True),
+        ("", False),
+        (None, False),
+        ("application/json", False),
+        ("application/activity+json", False),
+        ("application/json,text/html", False),
+        ("application/activity+json,text/html", False),
+    ],
+)
+def test_should_redirect_ap_to_html(accept_header, expected):
+    assert federation_utils.should_redirect_ap_to_html(accept_header) is expected
+
+
+def test_music_library_retrieve_redirects_to_html_if_header_set(
+    factories, api_client, settings
+):
+    library = factories["music.Library"](actor__local=True)
+
+    url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
+    response = api_client.get(url, HTTP_ACCEPT="text/html")
+    expected_url = utils.join_url(
+        settings.FUNKWHALE_URL,
+        utils.spa_reverse("library_library", kwargs={"uuid": library.uuid}),
+    )
+    assert response.status_code == 302
+    assert response["Location"] == expected_url
diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py
index ea4543ddb2e7902b4f29414820dffd0ea4ee2481..30f8f58b9ce275f7691a70aa5f444a7eb51893e2 100644
--- a/api/tests/music/test_filters.py
+++ b/api/tests/music/test_filters.py
@@ -142,3 +142,45 @@ def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_
     )
 
     assert filterset.qs == [upload.track.album]
+
+
+def test_library_filter_track(factories, queryset_equal_list, mocker, anonymous_user):
+    library = factories["music.Library"](privacy_level="everyone")
+    upload = factories["music.Upload"](library=library, playable=True)
+    factories["music.Track"]()
+    qs = upload.track.__class__.objects.all()
+    filterset = filters.TrackFilter(
+        {"library": library.uuid},
+        request=mocker.Mock(user=anonymous_user, actor=None),
+        queryset=qs,
+    )
+
+    assert filterset.qs == [upload.track]
+
+
+def test_library_filter_album(factories, queryset_equal_list, mocker, anonymous_user):
+    library = factories["music.Library"](privacy_level="everyone")
+    upload = factories["music.Upload"](library=library, playable=True)
+    factories["music.Album"]()
+    qs = upload.track.album.__class__.objects.all()
+    filterset = filters.AlbumFilter(
+        {"library": library.uuid},
+        request=mocker.Mock(user=anonymous_user, actor=None),
+        queryset=qs,
+    )
+
+    assert filterset.qs == [upload.track.album]
+
+
+def test_library_filter_artist(factories, queryset_equal_list, mocker, anonymous_user):
+    library = factories["music.Library"](privacy_level="everyone")
+    upload = factories["music.Upload"](library=library, playable=True)
+    factories["music.Artist"]()
+    qs = upload.track.artist.__class__.objects.all()
+    filterset = filters.ArtistFilter(
+        {"library": library.uuid},
+        request=mocker.Mock(user=anonymous_user, actor=None),
+        queryset=qs,
+    )
+
+    assert filterset.qs == [upload.track.artist]
diff --git a/api/tests/music/test_spa_views.py b/api/tests/music/test_spa_views.py
index d5deb8ce685a1782c5934b49436daf628dc1e4a2..ffb763a1b42e0e73b702f4c23849e517a949e099 100644
--- a/api/tests/music/test_spa_views.py
+++ b/api/tests/music/test_spa_views.py
@@ -282,3 +282,32 @@ def test_library_playlist_empty(spa_html, no_api_auth, client, factories, settin
 
     # we only test our custom metas, not the default ones
     assert metas[: len(expected_metas)] == expected_metas
+
+
+def test_library_library(spa_html, no_api_auth, client, factories, settings):
+    library = factories["music.Library"]()
+    url = "/library/{}".format(library.uuid)
+
+    response = client.get(url)
+
+    expected_metas = [
+        {
+            "tag": "meta",
+            "property": "og:url",
+            "content": utils.join_url(settings.FUNKWHALE_URL, url),
+        },
+        {"tag": "meta", "property": "og:type", "content": "website"},
+        {"tag": "meta", "property": "og:title", "content": library.name},
+        {"tag": "meta", "property": "og:description", "content": library.description},
+        {
+            "tag": "link",
+            "rel": "alternate",
+            "type": "application/activity+json",
+            "href": library.fid,
+        },
+    ]
+
+    metas = utils.parse_meta(response.content.decode())
+
+    # we only test our custom metas, not the default ones
+    assert metas[: len(expected_metas)] == expected_metas
diff --git a/changes/changelog.d/926.feature b/changes/changelog.d/926.feature
new file mode 100644
index 0000000000000000000000000000000000000000..00d3f7073788ec4ebb057db090727b39bfe8f3ee
--- /dev/null
+++ b/changes/changelog.d/926.feature
@@ -0,0 +1 @@
+Can now browse a library content through the UI (#926)
diff --git a/front/src/components/audio/LibraryFollowButton.vue b/front/src/components/audio/LibraryFollowButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2b418edc48f55c1355ca944b05c8d5ffd74e1921
--- /dev/null
+++ b/front/src/components/audio/LibraryFollowButton.vue
@@ -0,0 +1,43 @@
+ <template>
+  <button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']">
+    <i class="heart icon"></i>
+    <translate v-if="isApproved" translate-context="Content/Library/Card.Button.Label/Verb">Unfollow</translate>
+    <translate v-else-if="isPending" translate-context="Content/Library/Card.Button.Label/Verb">Cancel follow request</translate>
+    <translate v-else translate-context="Content/Library/Card.Button.Label/Verb">Follow</translate>
+  </button>
+</template>
+
+<script>
+export default {
+  props: {
+    library: {type: Object},
+  },
+  computed: {
+    isPending () {
+      return this.follow && this.follow.approved === null
+    },
+    isApproved () {
+      return this.follow && (this.follow.approved === true || (this.follow.approved === null && this.library.privacy_level === 'everyone'))
+    },
+    follow () {
+      return this.$store.getters['libraries/follow'](this.library.uuid)
+    }
+  },
+  methods: {
+    toggle () {
+      if (this.isApproved || this.isPending) {
+        this.$emit('unfollowed')
+      } else {
+        this.$emit('followed')
+      }
+      this.$store.dispatch('libraries/toggle', this.library.uuid)
+    }
+  }
+
+
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index a4bb95692803e415a1e43ca683c364de6d2af30d..b8c1e42fe2a3c19dce5393586a3493e902d25018 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -68,6 +68,7 @@ export default {
     iconOnly: {type: Boolean, default: false},
     artist: {type: Object, required: false},
     album: {type: Object, required: false},
+    library: {type: Object, required: false},
     isPlayable: {type: Boolean, required: false, default: null}
   },
   data () {
@@ -196,6 +197,9 @@ export default {
         } else if (self.album) {
           let params = {'album': self.album.id, include_channels: 'true', 'ordering': 'disc_number,position'}
           self.getTracksPage(1, params, resolve)
+        } else if (self.library) {
+          let params = {'library': self.library.uuid, 'ordering': '-creation_date'}
+          self.getTracksPage(1, params, resolve)
         }
       })
       return getTracks.then((tracks) => {
diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue
index fc9cebaeb0c51366360bd7b78ddf357979606855..33bc447323fcdbd49612b63daaea2800e315c6ce 100644
--- a/front/src/components/audio/album/Widget.vue
+++ b/front/src/components/audio/album/Widget.vue
@@ -5,6 +5,7 @@
       <span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
     </h3>
     <slot></slot>
+    <inline-search-bar v-model="query" v-if="search" @search="albums = []; fetchData()"></inline-search-bar>
     <div class="ui hidden divider"></div>
     <div class="ui app-cards cards">
       <div v-if="isLoading" class="ui inverted active dimmer">
@@ -12,14 +13,9 @@
       </div>
       <album-card v-for="album in albums" :album="album" :key="album.id" />
     </div>
-    <template v-if="!isLoading && albums.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>
+    <slot v-if="!isLoading && albums.length === 0" name="empty-state">
+      <empty-state @refresh="fetchData" :refresh="true"></empty-state>
+    </slot>
     <template v-if="nextPage">
       <div class="ui hidden divider"></div>
       <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
@@ -30,7 +26,6 @@
 </template>
 
 <script>
-import _ from '@/lodash'
 import axios from 'axios'
 import AlbumCard from '@/components/audio/album/Card'
 
@@ -39,6 +34,7 @@ export default {
     filters: {type: Object, required: true},
     controls: {type: Boolean, default: true},
     showCount: {type: Boolean, default: false},
+    search: {type: Boolean, default: false},
     limit: {type: Number, default: 12},
   },
   components: {
@@ -51,20 +47,19 @@ export default {
       isLoading: false,
       errors: null,
       previousPage: null,
-      nextPage: null
+      nextPage: null,
+      query: '',
     }
   },
   created () {
-    this.fetchData('albums/')
+    this.fetchData()
   },
   methods: {
     fetchData (url) {
-      if (!url) {
-        return
-      }
+      url = url || 'albums/'
       this.isLoading = true
       let self = this
-      let params = _.clone(this.filters)
+      let params = {q: this.query, ...this.filters}
       params.page_size = this.limit
       params.offset = this.offset
       axios.get(url, {params: params}).then((response) => {
@@ -91,7 +86,7 @@ export default {
       this.fetchData()
     },
     "$store.state.moderation.lastUpdate": function () {
-      this.fetchData('albums/')
+      this.fetchData()
     }
   }
 }
diff --git a/front/src/components/audio/artist/Widget.vue b/front/src/components/audio/artist/Widget.vue
index d3e946a841dfe1bd80b4c0cdc9f43be063fdc367..733bb9b2b05c5196a773e907911c82f531c07e1f 100644
--- a/front/src/components/audio/artist/Widget.vue
+++ b/front/src/components/audio/artist/Widget.vue
@@ -4,6 +4,7 @@
       <slot name="title"></slot>
       <span class="ui tiny circular label">{{ count }}</span>
     </h3>
+    <inline-search-bar v-model="query" v-if="search" @search="objects = []; fetchData()"></inline-search-bar>
     <div class="ui hidden divider"></div>
     <div class="ui five app-cards cards">
       <div v-if="isLoading" class="ui inverted active dimmer">
@@ -11,7 +12,9 @@
       </div>
       <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>
+    <slot v-if="!isLoading && objects.length === 0" name="empty-state">
+      <empty-state @refresh="fetchData" :refresh="true"></empty-state>
+    </slot>
     <template v-if="nextPage">
       <div class="ui hidden divider"></div>
       <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
@@ -22,7 +25,6 @@
 </template>
 
 <script>
-import _ from '@/lodash'
 import axios from 'axios'
 import ArtistCard from "@/components/audio/artist/Card"
 
@@ -31,6 +33,7 @@ export default {
     filters: {type: Object, required: true},
     controls: {type: Boolean, default: true},
     header: {type: Boolean, default: true},
+    search: {type: Boolean, default: false},
   },
   components: {
     ArtistCard,
@@ -43,20 +46,19 @@ export default {
       isLoading: false,
       errors: null,
       previousPage: null,
-      nextPage: null
+      nextPage: null,
+      query: '',
     }
   },
   created () {
-    this.fetchData('artists/')
+    this.fetchData()
   },
   methods: {
     fetchData (url) {
-      if (!url) {
-        return
-      }
+      url = url || 'artists/'
       this.isLoading = true
       let self = this
-      let params = _.clone(this.filters)
+      let params = {q: this.query, ...this.filters}
       params.page_size = this.limit
       params.offset = this.offset
       axios.get(url, {params: params}).then((response) => {
@@ -83,7 +85,7 @@ export default {
       this.fetchData()
     },
     "$store.state.moderation.lastUpdate": function () {
-      this.fetchData('objects/')
+      this.fetchData()
     }
   }
 }
diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue
index 5bbd046c23a0e5af7cfd1126d3075330b42929f2..9afd67dd384625c213521262afd065b3d339b29f 100644
--- a/front/src/components/audio/track/Row.vue
+++ b/front/src/components/audio/track/Row.vue
@@ -31,7 +31,7 @@
     <td colspan="4" v-else>
       <translate translate-context="*/*/*">N/A</translate>
     </td>
-    <td colspan="2" class="align right">
+    <td colspan="2" v-if="displayActions" class="align right">
       <track-favorite-icon class="favorite-icon" :track="track"></track-favorite-icon>
       <play-button
         class="play-button basic icon"
@@ -59,6 +59,7 @@ export default {
     track: {type: Object, required: true},
     artist: {type: Object, required: false},
     displayPosition: {type: Boolean, default: false},
+    displayActions: {type: Boolean, default: true},
     playable: {type: Boolean, required: false, default: false},
   },
   components: {
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index 14515b4c324ece2abe16db9052b2cb0fb3e95b57..ed91b79a26353f74664bf727ccff0a3a8a339ddc 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -1,6 +1,10 @@
 <template>
   <div class="table-wrapper">
-    <table class="ui compact very basic unstackable table">
+    <inline-search-bar v-model="query" v-if="search" @search="additionalTracks = []; loadMore()"></inline-search-bar>
+    <slot v-if="!isLoading && allTracks.length === 0" name="empty-state">
+      <empty-state @refresh="fetchData" :refresh="true"></empty-state>
+    </slot>
+    <table v-else :class="['ui', 'compact', 'very', 'basic', {loading: isLoading}, 'unstackable', 'table']">
       <thead>
         <tr>
           <th></th>
@@ -9,20 +13,21 @@
           <th colspan="4"><translate translate-context="*/*/*/Noun">Artist</translate></th>
           <th colspan="4"><translate translate-context="*/*/*">Album</translate></th>
           <th colspan="4"><translate translate-context="Content/*/*">Duration</translate></th>
-          <th colspan="2"></th>
+          <th colspan="2" v-if="displayActions"></th>
         </tr>
       </thead>
       <tbody>
         <track-row
           :playable="playable"
           :display-position="displayPosition"
+          :display-actions="displayActions"
           :track="track"
           :artist="artist"
           :key="index + '-' + track.id"
           v-for="(track, index) in allTracks"></track-row>
       </tbody>
     </table>
-    <button :class="['ui', {loading: isLoadingMore}, 'button']" v-if="loadMoreUrl" @click="loadMore(loadMoreUrl)">
+    <button :class="['ui', {loading: isLoading}, 'button']" v-if="loadMoreUrl" @click="loadMore(loadMoreUrl)" :disabled="isLoading">
       <translate translate-context="Content/*/Button.Label">Load more…</translate>
     </button>
   </div>
@@ -36,38 +41,49 @@ import Modal from '@/components/semantic/Modal'
 
 export default {
   props: {
-    tracks: {type: Array, required: true},
+    tracks: {type: Array, required: false},
     playable: {type: Boolean, required: false, default: false},
+    search: {type: Boolean, required: false, default: false},
     nextUrl: {type: String, required: false, default: null},
     artist: {type: Object, required: false},
-    displayPosition: {type: Boolean, default: false}
+    filters: {type: Object, required: false, default: () => { return {}}},
+    displayPosition: {type: Boolean, default: false},
+    displayActions: {type: Boolean, default: true},
   },
   components: {
     Modal,
     TrackRow
   },
+  created () {
+    if (!this.tracks) {
+      this.loadMore('tracks/')
+    }
+  },
   data () {
     return {
       loadMoreUrl: this.nextUrl,
-      isLoadingMore: false,
-      additionalTracks: []
+      isLoading: false,
+      additionalTracks: [],
+      query: '',
     }
   },
   computed: {
     allTracks () {
-      return this.tracks.concat(this.additionalTracks)
+      return (this.tracks || []).concat(this.additionalTracks)
     }
   },
   methods: {
     loadMore (url) {
+      url = url || 'tracks/'
       let self = this
-      self.isLoadingMore = true
-      axios.get(url).then((response) => {
+      let params = {q: this.query, ...this.filters}
+      self.isLoading = true
+      axios.get(url, {params}).then((response) => {
         self.additionalTracks = self.additionalTracks.concat(response.data.results)
         self.loadMoreUrl = response.data.next
-        self.isLoadingMore = false
+        self.isLoading = false
       }, (error) => {
-        self.isLoadingMore = false
+        self.isLoading = false
 
       })
     }
diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue
index 8c6c092febc76ea3cc2dc0d935435fddbba221e1..ce2be46a294732ba3ca88217cd0aafaf00d65581 100644
--- a/front/src/components/common/ActorLink.vue
+++ b/front/src/components/common/ActorLink.vue
@@ -1,6 +1,6 @@
 <template>
   <router-link :to="url" :title="actor.full_username">
-    <template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template>{{ repr | truncate(30) }}
+    <template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template><slot>{{ repr | truncate(truncateLength) }}</slot>
   </router-link>
 </template>
 
@@ -13,6 +13,7 @@ export default {
     avatar: {type: Boolean, default: true},
     admin: {type: Boolean, default: false},
     displayName: {type: Boolean, default: false},
+    truncateLength: {type: Number, default: 30},
   },
   computed: {
     url () {
diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue
index 989bb0346cc0fef0548922b629c94ea575b26c68..f6637297e54ef259b02be94859f1c00b73fae6c2 100644
--- a/front/src/components/common/CopyInput.vue
+++ b/front/src/components/common/CopyInput.vue
@@ -3,7 +3,7 @@
     <p class="message" v-if="copied">
       <translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate>
     </p>
-    <input ref="input" :value="value" type="text" readonly>
+    <input :id="id" :name="id" ref="input" :value="value" type="text" readonly>
     <button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']">
       <i class="copy icon"></i>
       <translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate>
@@ -14,7 +14,8 @@
 export default {
   props: {
     value: {type: String},
-    buttonClasses: {type: String, default: 'teal'}
+    buttonClasses: {type: String, default: 'teal'},
+    id: {type: String, default: 'copy-input'},
   },
   data () {
     return {
diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue
index 862700679e2a5e9db60d756d26f6081fdb7d9b7a..360ef58f4dea75f815658b930039be11718d8a71 100644
--- a/front/src/components/common/EmptyState.vue
+++ b/front/src/components/common/EmptyState.vue
@@ -11,7 +11,7 @@
         </slot>
       </div>
     </div>
-    <div class="inline">
+    <div class="inline center aligned text">
       <slot></slot>
       <button v-if="refresh" class="ui button" @click="$emit('refresh')">
         <translate translate-context="Content/*/Button.Label/Short, Verb">
diff --git a/front/src/components/common/InlineSearchBar.vue b/front/src/components/common/InlineSearchBar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0ba6d85a9685d1743d8d3b00852afbf1c319c722
--- /dev/null
+++ b/front/src/components/common/InlineSearchBar.vue
@@ -0,0 +1,32 @@
+<template>
+  <form class="ui inline form" @submit.stop.prevent="$emit('search', value)">
+    <div :class="['ui', 'action', {icon: isClearable}, 'input']">
+      <label for="search-query" class="hidden">
+        <translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
+      </label>
+      <input id="search-query" name="search-query" type="text" :placeholder="labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)">
+      <i v-if="isClearable" class="x link icon" :title="labels.clear" @click="$emit('input', ''); $emit('search', value)"></i>
+      <button type="submit" class="ui icon basic button">
+        <i class="search icon"></i>
+      </button>
+    </div>
+  </form>
+</template>
+<script>
+export default {
+  props: {
+    value: {type: String, required: true}
+  },
+  computed: {
+    labels () {
+      return {
+        searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search…'),
+        clear: this.$pgettext("Content/Library/Button.Label", 'Clear'),
+      }
+    },
+    isClearable () {
+      return !!this.value
+    }
+  }
+}
+</script>
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
index 289fd625e5c53246c61f5f6e5798638fff557d96..e3eac9abb7418808b65a20074caf7613d4941949 100644
--- a/front/src/components/globals.js
+++ b/front/src/components/globals.js
@@ -18,5 +18,6 @@ Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/
 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"))
+Vue.component('inline-search-bar', () => import(/* webpackChunkName: "common" */ "@/components/common/InlineSearchBar"))
 
 export default {}
diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue
index 7aed6ade5b8190aff63d6fe62e8222ee7725d49b..2086f62917bb3272ba8f4d5e4baba4c31edcb45c 100644
--- a/front/src/components/library/FileUpload.vue
+++ b/front/src/components/library/FileUpload.vue
@@ -45,17 +45,17 @@
         </ul>
       </div>
 
-      <div class="ui form">
+      <form class="ui form" @submit.prevent="currentTab = 'uploads'">
         <div class="fields">
-          <div class="ui four wide field">
+          <div class="ui field">
             <label><translate translate-context="Content/Library/Input.Label/Noun">Import reference</translate></label>
             <p><translate translate-context="Content/Library/Paragraph">This reference will be used to group imported files together.</translate></p>
             <input name="import-ref" type="text" v-model="importReference" />
           </div>
         </div>
 
-      </div>
-      <div class="ui green button" @click="currentTab = 'uploads'"><translate translate-context="Content/Library/Button.Label">Proceed</translate></div>
+        <button type="submit" class="ui green button"><translate translate-context="Content/Library/Button.Label">Proceed</translate></button>
+      </form>
     </div>
     <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
       <div :class="['ui', {loading: isLoadingQuota}, 'container']">
@@ -149,6 +149,7 @@
     <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]">
       <library-files-table
         :needs-refresh="needsRefresh"
+        ordering-config-name="library.detail.upload"
         @fetch-start="needsRefresh = false"
         :filters="{import_reference: importReference}"
         :custom-objects="Object.values(uploads.objects)"></library-files-table>
@@ -253,14 +254,6 @@ export default {
           });
       });
     },
-    updateProgressBar() {
-      $(this.$el)
-        .find(".progress")
-        .progress({
-          total: this.uploads.length * 2,
-          value: this.uploadedFilesCount + this.finishedJobs
-        });
-    },
     handleImportEvent(event) {
       let self = this;
       if (event.upload.import_reference != self.importReference) {
@@ -387,12 +380,6 @@ export default {
     }
   },
   watch: {
-    uploadedFilesCount() {
-      this.updateProgressBar();
-    },
-    finishedJobs() {
-      this.updateProgressBar();
-    },
     importReference: _.debounce(function() {
       this.$router.replace({ query: { import: this.importReference } });
     }, 500),
@@ -400,6 +387,11 @@ export default {
       if (newValue <= 0) {
         this.$refs.upload.active = false;
       }
+    },
+    'uploads.finished' (v, o) {
+      if (v > o) {
+        this.$emit('uploads-finished', v - o)
+      }
     }
   }
 };
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index ed528cd9656a50737eac7042f66d8f7c0de767bd..2b22a4ca6eda90366737f5c83ebbbcf0f6debbbe 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -1,6 +1,6 @@
 <template>
   <div class="main library pusher">
-    <router-view :key="$route.fullPath"></router-view>
+    <router-view></router-view>
   </div>
 </template>
 
diff --git a/front/src/components/mixins/Ordering.vue b/front/src/components/mixins/Ordering.vue
index ec11b0b00c6b002bdfa3fc0a9a8355faa79c35d5..d91f28db54cbf49f926bd08dec55540d19aa7457 100644
--- a/front/src/components/mixins/Ordering.vue
+++ b/front/src/components/mixins/Ordering.vue
@@ -1,11 +1,12 @@
 <script>
 export default {
   props: {
-    defaultOrdering: {type: String, required: false}
+    defaultOrdering: {type: String, required: false},
+    orderingConfigName: {type: String, required: false},
   },
   computed: {
     orderingConfig () {
-      return this.$store.state.ui.routePreferences[this.$route.name]
+      return this.$store.state.ui.routePreferences[this.orderingConfigName || this.$route.name]
     },
     paginateBy: {
       set(paginateBy) {
diff --git a/front/src/filters.js b/front/src/filters.js
index 91d4e455ca94661a3daa97daa55c423a6fa5c7c2..6465e973a8f5754853132dd6075504b87b7fe858 100644
--- a/front/src/filters.js
+++ b/front/src/filters.js
@@ -3,6 +3,9 @@ import Vue from 'vue'
 import moment from 'moment'
 
 export function truncate (str, max, ellipsis, middle) {
+  if (max === 0) {
+    return
+  }
   max = max || 100
   ellipsis = ellipsis || '…'
   if (str.length <= max) {
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 15350a4fff8a012682df3eb3ffe4e0fea72fb473..07bb210e32dfef63518e2b33d52e29e29951b805 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -233,27 +233,6 @@ export default new Router({
             import(
               /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Home"
             )
-        },
-        {
-          path: ":id/upload",
-          name: "content.libraries.detail.upload",
-          component: () =>
-            import(
-              /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Upload"
-            ),
-          props: route => ({
-            id: route.params.id,
-            defaultImportReference: route.query.import
-          })
-        },
-        {
-          path: ":id",
-          name: "content.libraries.detail",
-          component: () =>
-            import(
-              /* webpackChunkName: "auth-libraries" */ "@/views/content/libraries/Detail"
-            ),
-          props: true
         }
       ]
     },
@@ -812,6 +791,68 @@ export default new Router({
               props: true
             }
           ]
+        },
+        {
+          // browse a single library via it's uuid
+          path: ":id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})",
+          props: true,
+          component: () =>
+            import(
+              /* webpackChunkName: "library" */ "@/views/library/DetailBase"
+            ),
+          children: [
+            {
+              path: "",
+              name: "library.detail",
+              component: () =>
+                import(
+                  /* webpackChunkName: "library" */ "@/views/library/DetailOverview"
+                )
+            },
+            {
+              path: "albums",
+              name: "library.detail.albums",
+              component: () =>
+                import(
+                  /* webpackChunkName: "library" */ "@/views/library/DetailAlbums"
+                )
+            },
+            {
+              path: "tracks",
+              name: "library.detail.tracks",
+              component: () =>
+                import(
+                  /* webpackChunkName: "library" */ "@/views/library/DetailTracks"
+                )
+            },
+            {
+              path: "edit",
+              name: "library.detail.edit",
+              component: () =>
+                import(
+                  /* webpackChunkName: "auth-libraries" */ "@/views/library/Edit"
+                )
+            },
+            {
+              path: "upload",
+              name: "library.detail.upload",
+              component: () =>
+                import(
+                  /* webpackChunkName: "auth-libraries" */ "@/views/library/Upload"
+                ),
+              props: route => ({
+                defaultImportReference: route.query.import
+              })
+            },
+            // {
+            //   path: "episodes",
+            //   name: "library.detail.episodes",
+            //   component: () =>
+            //     import(
+            //       /* webpackChunkName: "library" */ "@/views/library/DetailEpisodes"
+            //     )
+            // },
+          ]
         }
       ]
     },
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index 49f8ddde54121a51b09a7d9aaa14ad56966654d0..f11f8ee7faf1664b5b1ea65e1abd03d09e421a40 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -143,6 +143,7 @@ export default {
           }
           dispatch('favorites/fetch', null, { root: true })
           dispatch('channels/fetchSubscriptions', null, { root: true })
+          dispatch('libraries/fetchFollows', null, { root: true })
           dispatch('moderation/fetchContentFilters', null, { root: true })
           dispatch('playlists/fetchOwn', null, { root: true })
         }, (response) => {
diff --git a/front/src/store/index.js b/front/src/store/index.js
index c098aa1ec5436ff88e867a7e359efed0a8f21336..33323749f6a78d10def462cbb0eb47799ded3b71 100644
--- a/front/src/store/index.js
+++ b/front/src/store/index.js
@@ -4,6 +4,7 @@ import createPersistedState from 'vuex-persistedstate'
 
 import favorites from './favorites'
 import channels from './channels'
+import libraries from './libraries'
 import auth from './auth'
 import instance from './instance'
 import moderation from './moderation'
@@ -20,6 +21,7 @@ export default new Vuex.Store({
     ui,
     auth,
     channels,
+    libraries,
     favorites,
     instance,
     moderation,
diff --git a/front/src/store/libraries.js b/front/src/store/libraries.js
new file mode 100644
index 0000000000000000000000000000000000000000..77632de8be1afbbb588db4ac3094db714dba0c4d
--- /dev/null
+++ b/front/src/store/libraries.js
@@ -0,0 +1,73 @@
+import axios from 'axios'
+import logger from '@/logging'
+
+export default {
+  namespaced: true,
+  state: {
+    followedLibraries: [],
+    followsByLibrary: {},
+    count: 0,
+  },
+  mutations: {
+    follows: (state, {library, follow}) => {
+      let replacement = {...state.followsByLibrary}
+      if (follow) {
+        if (state.followedLibraries.indexOf(library) === -1) {
+          state.followedLibraries.push(library)
+          replacement[library] = follow
+        }
+      } else {
+        let i = state.followedLibraries.indexOf(library)
+        if (i > -1) {
+          state.followedLibraries.splice(i, 1)
+          replacement[library] = null
+        }
+      }
+      state.followsByLibrary = replacement
+      state.count = state.followedLibraries.length
+    },
+    reset (state) {
+      state.followedLibraries = []
+      state.followsByLibrary = {}
+      state.count = 0
+    },
+  },
+  getters: {
+    follow: (state) => (library) => {
+      return state.followsByLibrary[library]
+    }
+  },
+  actions: {
+    set ({commit, state}, {uuid, value}) {
+      if (value) {
+        return axios.post(`federation/follows/library/`, {target: uuid}).then((response) => {
+          logger.default.info('Successfully subscribed to library')
+          commit('follows', {library: uuid, follow: response.data})
+        }, (response) => {
+          logger.default.info('Error while subscribing to library')
+          commit('follows', {library: uuid, follow: null})
+        })
+      } else {
+        let follow = state.followsByLibrary[uuid]
+        return axios.delete(`federation/follows/library/${follow.uuid}/`).then((response) => {
+          logger.default.info('Successfully unsubscribed from library')
+          commit('follows', {library: uuid, follow: null})
+        }, (response) => {
+          logger.default.info('Error while unsubscribing from library')
+          commit('follows', {library: uuid, follow: follow})
+        })
+      }
+    },
+    toggle ({getters, dispatch}, uuid) {
+      dispatch('set', {uuid, value: !getters['follow'](uuid)})
+    },
+    fetchFollows ({dispatch, state, commit, rootState}, url) {
+      let promise = axios.get('federation/follows/library/all/')
+      return promise.then((response) => {
+        response.data.results.forEach(result => {
+          commit('follows', {library: result.library, follow: result})
+        })
+      })
+    }
+  }
+}
diff --git a/front/src/store/ui.js b/front/src/store/ui.js
index e2ca0e636a52485ba22ee74ae95ffe6dc07cb2e3..a666af1ee089c62fb4e84789e0709b542c105b6c 100644
--- a/front/src/store/ui.js
+++ b/front/src/store/ui.js
@@ -77,12 +77,17 @@ export default {
         orderingDirection: "-",
         ordering: "creation_date",
       },
-      "content.libraries.detail": {
+      "library.detail.upload": {
         paginateBy: 50,
         orderingDirection: "-",
         ordering: "creation_date",
       },
-      "content.libraries.detail.upload": {
+      "library.detail.edit": {
+        paginateBy: 50,
+        orderingDirection: "-",
+        ordering: "creation_date",
+      },
+      "library.detail": {
         paginateBy: 50,
         orderingDirection: "-",
         ordering: "creation_date",
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index 435851ea9c706d8ff13b06290840d36fde4edc89..76bbc35cb0272cdd3f53a42e07edbf866c5fd036 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -174,9 +174,6 @@ html {
   }
 }
 
-.main-pusher {
-  padding: 1.5rem 0;
-}
 .ui.stripe.segment,
 #footer {
   padding: 1em;
@@ -198,6 +195,9 @@ html {
 .center.aligned.menu {
   justify-content: center;
 }
+.text.center.aligned {
+  text-align: center;
+}
 .ellipsis:not(.icon) {
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -659,5 +659,8 @@ input + .help {
 .modal > .header {
   text-align: center;
 }
+.ui.header .content {
+  display: block;
+}
 @import "./themes/_light.scss";
 @import "./themes/_dark.scss";
diff --git a/front/src/views/auth/ProfileOverview.vue b/front/src/views/auth/ProfileOverview.vue
index 5c524a107abe206cceab53fcd11903499a94297b..12275d1a651765ed60017fa243feed0fd292965d 100644
--- a/front/src/views/auth/ProfileOverview.vue
+++ b/front/src/views/auth/ProfileOverview.vue
@@ -20,8 +20,15 @@
         </div>
       </h2>
       <channels-widget :filters="{scope: `actor:${object.full_username}`}"></channels-widget>
-      <h2 class="ui header">
+      <h2 class="ui with-actions header">
         <translate translate-context="Content/Profile/Header">User Libraries</translate>
+        <div class="actions" v-if="$store.state.auth.authenticated && object.full_username === $store.state.auth.fullUsername">
+          <router-link :to="{name: 'content.libraries.index'}">
+            <i class="plus icon"></i>
+            <translate translate-context="Content/Profile/Button">Add new</translate>
+          </router-link>
+        </div>
+
       </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>
diff --git a/front/src/views/content/libraries/Card.vue b/front/src/views/content/libraries/Card.vue
index 4e1b497df25fa85b98bf9ad514782b601765c770..6ca0f4a5aedbdda4144be63f3dbcc33d5ada7775 100644
--- a/front/src/views/content/libraries/Card.vue
+++ b/front/src/views/content/libraries/Card.vue
@@ -42,10 +42,10 @@
       </div>
     </div>
     <div class="ui bottom basic attached buttons">
-      <router-link :to="{name: 'content.libraries.detail.upload', params: {id: library.uuid}}" class="ui button">
+      <router-link :to="{name: 'library.detail.upload', params: {id: library.uuid}}" class="ui button">
         <translate translate-context="Content/Library/Card.Button.Label/Verb">Upload</translate>
       </router-link>
-      <router-link :to="{name: 'content.libraries.detail', params: {id: library.uuid}}" exact class="ui button">
+      <router-link :to="{name: 'library.detail', params: {id: library.uuid}}" exact class="ui button">
         <translate translate-context="Content/Library/Card.Button.Label/Noun">Details</translate>
       </router-link>
     </div>
diff --git a/front/src/views/content/libraries/Detail.vue b/front/src/views/content/libraries/Detail.vue
deleted file mode 100644
index c936baa8256ba2deb248465e324dfafd6bbec2aa..0000000000000000000000000000000000000000
--- a/front/src/views/content/libraries/Detail.vue
+++ /dev/null
@@ -1,129 +0,0 @@
-<template>
-  <section class="ui vertical aligned stripe segment">
-    <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']">
-      <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading library data…</translate></div>
-    </div>
-    <detail-area v-else :library="library">
-      <div class="ui top attached tabular menu">
-        <a :class="['item', {active: currentTab === 'follows'}]" @click="currentTab = 'follows'"><translate translate-context="Content/Federation/*/Noun">Followers</translate></a>
-        <a :class="['item', {active: currentTab === 'tracks'}]" @click="currentTab = 'tracks'"><translate translate-context="*/*/*">Tracks</translate></a>
-        <a :class="['item', {active: currentTab === 'edit'}]" @click="currentTab = 'edit'"><translate translate-context="Content/*/Button.Label/Verb">Edit</translate></a>
-      </div>
-      <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'follows'}]">
-        <div class="ui form">
-          <div class="field">
-            <label><translate translate-context="Content/Library/Title">Sharing link</translate></label>
-            <p><translate translate-context="Content/Library/Paragraph">Share this link with other users so they can request access to your library.</translate></p>
-            <copy-input :value="library.fid" />
-          </div>
-        </div>
-        <div class="ui hidden divider"></div>
-        <div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']">
-          <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading followers…</translate></div>
-        </div>
-        <table v-else-if="follows && follows.count > 0" class="ui table">
-          <thead>
-            <tr>
-              <th><translate translate-context="Content/Library/Table.Label">User</translate></th>
-              <th><translate translate-context="Content/Library/Table.Label">Date</translate></th>
-              <th><translate translate-context="*/*/*">Status</translate></th>
-              <th><translate translate-context="Content/Library/Table.Label">Action</translate></th>
-            </tr>
-          </thead>
-          <tr v-for="follow in follows.results" :key="follow.fid">
-            <td><actor-link :actor="follow.actor" /></td>
-            <td><human-date :date="follow.creation_date" /></td>
-            <td>
-              <span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null">
-                <translate translate-context="Content/Library/Table/Short">Pending approval</translate>
-              </span>
-              <span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true">
-                <translate translate-context="Content/Library/Table/Short">Accepted</translate>
-              </span>
-              <span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false">
-                <translate translate-context="Content/Library/*/Short">Rejected</translate>
-              </span>
-            </td>
-            <td>
-              <div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false">
-                <i class="ui check icon"></i> <translate translate-context="Content/Library/Button.Label">Accept</translate>
-              </div>
-              <div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true">
-                <i class="ui x icon"></i> <translate translate-context="Content/Library/Button.Label">Reject</translate>
-              </div>
-            </td>
-          </tr>
-
-        </table>
-        <p v-else><translate translate-context="Content/Library/Paragraph">Nobody is following this library</translate></p>
-      </div>
-      <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'tracks'}]">
-        <library-files-table :filters="{library: library.uuid}"></library-files-table>
-      </div>
-      <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'edit'}]">
-        <library-form :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
-      </div>
-    </detail-area>
-  </section>
-</template>
-
-<script>
-import axios from "axios"
-import DetailMixin from "./DetailMixin"
-import DetailArea from "./DetailArea"
-import LibraryForm from "./Form"
-import LibraryFilesTable from "./FilesTable"
-
-export default {
-  mixins: [DetailMixin],
-  components: {
-    DetailArea,
-    LibraryForm,
-    LibraryFilesTable
-  },
-  data() {
-    return {
-      currentTab: "follows",
-      isLoadingFollows: false,
-      follows: null
-    }
-  },
-  created() {
-    this.fetchFollows()
-  },
-  methods: {
-    libraryUpdated() {
-      this.hiddenForm = true
-      this.fetch()
-    },
-    libraryDeleted() {
-      this.$router.push({
-        name: "content.libraries.index"
-      })
-    },
-    fetchFollows() {
-      let self = this
-      self.isLoadingLibrary = true
-      axios.get(`libraries/${this.id}/follows/`).then(response => {
-        self.follows = response.data
-        self.isLoadingFollows = false
-      })
-    },
-    updateApproved(follow, value) {
-      let self = this
-      let action
-      if (value) {
-        action = "accept"
-      } else {
-        action = "reject"
-      }
-      axios
-        .post(`federation/follows/library/${follow.uuid}/${action}/`)
-        .then(response => {
-          follow.isLoading = false
-          follow.approved = value
-        })
-    }
-  }
-}
-</script>
diff --git a/front/src/views/content/libraries/DetailArea.vue b/front/src/views/content/libraries/DetailArea.vue
deleted file mode 100644
index 62928c05c1123bb2413047f01807275849e0fcbb..0000000000000000000000000000000000000000
--- a/front/src/views/content/libraries/DetailArea.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<template>
-  <div>
-    <div class="ui two column row">
-      <div class="column">
-        <h3 class="ui header"><translate translate-context="Content/Library/Title">Current library</translate></h3>
-        <library-card :library="library" />
-        <radio-button :type="'library'" :object-id="library.uuid"></radio-button>
-      </div>
-    </div>
-    <div class="ui hidden divider"></div>
-    <slot></slot>
-  </div>
-</template>
-
-<script>
-import RadioButton from '@/components/radios/Button'
-import LibraryCard from './Card'
-
-export default {
-  props: ['library'],
-  components: {
-    LibraryCard,
-    RadioButton,
-  },
-  computed: {
-    links () {
-      let upload = this.$pgettext('Content/Library/Card.Button.Label/Verb', 'Upload')
-      return [
-        {
-          name: 'libraries.detail.upload',
-          label: upload
-        }
-      ]
-    }
-  }
-}
-</script>
diff --git a/front/src/views/content/libraries/DetailMixin.vue b/front/src/views/content/libraries/DetailMixin.vue
deleted file mode 100644
index 92ff8452d88bbe026c413b92de9012c9a3401e8c..0000000000000000000000000000000000000000
--- a/front/src/views/content/libraries/DetailMixin.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import axios from 'axios'
-
-export default {
-  props: ['id'],
-  created () {
-    this.fetch()
-  },
-  data () {
-    return {
-      isLoadingLibrary: false,
-      library: null
-    }
-  },
-  methods: {
-    fetch () {
-      let self = this
-      self.isLoadingLibrary = true
-      axios.get(`libraries/${this.id}/`).then((response) => {
-        self.library = response.data
-        self.isLoadingLibrary = false
-      })
-    }
-  }
-}
-</script>
diff --git a/front/src/views/content/libraries/Home.vue b/front/src/views/content/libraries/Home.vue
index 69b4ce3a2863ed59aad101f3ccc2881c968bbdda..2e5e394988bc5be8c117544ca513750662e93dc4 100644
--- a/front/src/views/content/libraries/Home.vue
+++ b/front/src/views/content/libraries/Home.vue
@@ -62,8 +62,7 @@ export default {
       })
     },
     libraryCreated(library) {
-      this.hiddenForm = true
-      this.libraries.unshift(library)
+      this.$router.push({name: 'library.detail', params: {id: library.uuid}})
     }
   }
 }
diff --git a/front/src/views/content/libraries/Upload.vue b/front/src/views/content/libraries/Upload.vue
deleted file mode 100644
index 5fc7234ecd03fe82dd3019ba18d2997c55673aeb..0000000000000000000000000000000000000000
--- a/front/src/views/content/libraries/Upload.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<template>
-  <div class="ui vertical aligned stripe segment">
-    <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']">
-      <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading library data…</translate></div>
-    </div>
-    <detail-area v-else :library="library">
-      <file-upload ref="fileupload" :default-import-reference="defaultImportReference" :library="library" />
-    </detail-area>
-  </div>
-</template>
-
-<script>
-import DetailMixin from './DetailMixin'
-import DetailArea from './DetailArea'
-
-import FileUpload from '@/components/library/FileUpload'
-export default {
-  mixins: [DetailMixin],
-  props: ['defaultImportReference'],
-  components: {
-    DetailArea,
-    FileUpload
-  },
-  beforeRouteLeave (to, from, next){
-    if (this.$refs.fileupload.hasActiveUploads){
-      const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.')
-      if (answer) {
-        next()
-      } else {
-        next(false)
-      }
-    }
-    else{
-      next()
-    }
-  }
-}
-</script>
diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue
index 2ad16c03f0fe71db69b0e8e9c140d2b3ea87a9e6..decddc8fc97060e07436aad8b70584e0d5a2962d 100644
--- a/front/src/views/content/remote/Card.vue
+++ b/front/src/views/content/remote/Card.vue
@@ -2,7 +2,9 @@
   <div class="ui card">
     <div class="content">
       <div class="header">
-        {{ library.name }}
+        <router-link :to="{name: 'library.detail', params: {id: library.uuid}}">
+          {{ library.name }}
+        </router-link>
         <div class="ui right floated dropdown">
           <i class="ellipsis vertical grey large icon nomargin"></i>
           <div class="menu">
diff --git a/front/src/views/library/DetailAlbums.vue b/front/src/views/library/DetailAlbums.vue
new file mode 100644
index 0000000000000000000000000000000000000000..35dec511be654b3f2d0459d34e13c45849fd1500
--- /dev/null
+++ b/front/src/views/library/DetailAlbums.vue
@@ -0,0 +1,28 @@
+<template>
+  <section>
+    <album-widget
+      :key="String(object.uploads_count)"
+      :header="false"
+      :search="true"
+      :controls="false"
+      :filters="{playable: true, ordering: '-creation_date', library: object.uuid}">
+      <empty-state slot="empty-state">
+        <p>
+          <translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate>
+          <translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate>
+        </p>
+      </empty-state>
+    </album-widget>
+  </section>
+</template>
+
+<script>
+import AlbumWidget from "@/components/audio/album/Widget"
+
+export default {
+  props: ['object', 'isOwner'],
+  components: {
+    AlbumWidget,
+  },
+}
+</script>
diff --git a/front/src/views/library/DetailBase.vue b/front/src/views/library/DetailBase.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b480d189e49c9a5072be0ee416e69aec64a4e3b1
--- /dev/null
+++ b/front/src/views/library/DetailBase.vue
@@ -0,0 +1,197 @@
+<template>
+  <main v-title="labels.title">
+    <div class="ui vertical stripe segment container">
+      <div v-if="isLoading" class="ui centered active inline loader"></div>
+      <div class="ui stackable grid" v-else-if="object">
+        <div class="ui five wide column">
+          <div class="ui pointing dropdown icon small basic right floated button" ref="dropdown" v-dropdown="{direction: 'downward'}" style="position: absolute; right: 1em; top: 1em; z-index: 5">
+            <i class="ellipsis vertical icon"></i>
+            <div class="menu">
+              <div
+                role="button"
+                class="basic item"
+                v-for="obj in getReportableObjs({library: 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.library.libraries.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>
+          <h1 class="ui header">
+            <div class="ui hidden divider"></div>
+            <div class="ellipsis content">
+              <i class="layer group small icon"></i>
+              <span :title="object.name">{{ object.name }}</span>
+              <div class="ui very small hidden divider"></div>
+              <div class="sub header ellipsis" :title="object.full_username">
+                <actor-link :avatar="false" :actor="object.actor" :truncate-length="0">
+                  <translate translate-context="*/*/*" :translate-params="{username: object.actor.full_username}">Owned by %{ username }</translate>
+                </actor-link>
+              </div>
+            </div>
+          </h1>
+          <p>
+            <span v-if="object.privacy_level === 'me'" :title="labels.tooltips.me">
+              <i class="lock icon"></i>
+              {{ labels.visibility.me }}
+            </span>
+            <span
+              v-else-if="object.privacy_level === 'instance'" :title="labels.tooltips.instance">
+              <i class="lock open icon"></i>
+              {{ labels.visibility.instance }}
+            </span>
+            <span v-else-if="object.privacy_level === 'everyone'" :title="labels.tooltips.everyone">
+              <i class="globe icon"></i>
+              {{ labels.visibility.everyone }}
+            </span> ·
+            <i class="music icon"></i>
+            <translate translate-context="*/*/*" :translate-params="{count: object.uploads_count}" :translate-n="object.uploads_count" translate-plural="%{ count } tracks">%{ count } track</translate>
+            <span v-if="object.size">
+              · <i class="database icon"></i>
+              {{ object.size | humanSize }}
+            </span>
+          </p>
+
+          <div class="header-buttons">
+            <div class="ui small buttons">
+              <radio-button :disabled="!isPlayable" type="library" :object-id="object.uuid"></radio-button>
+            </div>
+            <div class="ui small buttons" v-if="!isOwner">
+              <library-follow-button v-if="$store.state.auth.authenticated" :library="object"></library-follow-button>
+            </div>
+          </div>
+
+          <template v-if="$store.getters['ui/layoutVersion'] === 'large'">
+            <rendered-description
+              :content="object.description ? {html: object.description} : null"
+              :update-url="`channels/${object.uuid}/`"
+              :can-update="false"></rendered-description>
+              <div class="ui hidden divider"></div>
+          </template>
+          <h5 class="ui header">
+            <label for="copy-input">
+              <translate translate-context="Content/Library/Title">Sharing link</translate>
+            </label>
+          </h5>
+          <p><translate translate-context="Content/Library/Paragraph">Share this link with other users so they can request access to this library by copy-pasting it in their pod search bar.</translate></p>
+          <copy-input :value="object.fid" />
+        </div>
+        <div class="ui eleven wide column">
+          <div class="ui head vertical stripe segment">
+            <div class="ui container">
+              <div class="ui secondary pointing center aligned menu">
+                <router-link class="item" :exact="true" :to="{name: 'library.detail'}">
+                  <translate translate-context="*/*/*">Artists</translate>
+                </router-link>
+                <router-link class="item" :exact="true" :to="{name: 'library.detail.albums'}">
+                  <translate translate-context="*/*/*">Albums</translate>
+                </router-link>
+                <router-link class="item" :exact="true" :to="{name: 'library.detail.tracks'}">
+                  <translate translate-context="*/*/*">Tracks</translate>
+                </router-link>
+                <router-link v-if="isOwner" class="item" :exact="true" :to="{name: 'library.detail.upload'}">
+                  <i class="upload icon"></i>
+                  <translate translate-context="Content/Library/Card.Button.Label/Verb">Upload</translate>
+                </router-link>
+                <router-link v-if="isOwner" class="item" :exact="true" :to="{name: 'library.detail.edit'}">
+                  <i class="pencil icon"></i>
+                  <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
+                </router-link>
+              </div>
+              <div class="ui hidden divider"></div>
+              <keep-alive>
+                <router-view
+                  @updated="fetchData"
+                  @uploads-finished="object.uploads_count += $event"
+                  :is-owner="isOwner"
+                  :object="object"></router-view>
+              </keep-alive>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </main>
+</template>
+
+<script>
+import axios from "axios"
+import PlayButton from "@/components/audio/PlayButton"
+import LibraryFollowButton from "@/components/audio/LibraryFollowButton"
+import ReportMixin from '@/components/mixins/Report'
+import RadioButton from '@/components/radios/Button'
+
+export default {
+  mixins: [ReportMixin],
+  props: ["id"],
+  components: {
+    PlayButton,
+    RadioButton,
+    LibraryFollowButton
+  },
+  data() {
+    return {
+      isLoading: true,
+      object: null,
+      latestTracks: null,
+    }
+  },
+  beforeRouteUpdate (to, from, next) {
+    to.meta.preserveScrollPosition = true
+    next()
+  },
+  async created() {
+    await this.fetchData()
+  },
+  methods: {
+    async fetchData() {
+      var self = this
+      this.isLoading = true
+      let libraryPromise = axios.get(`libraries/${this.id}`).then(response => {
+        self.object = response.data
+      })
+      await libraryPromise
+      self.isLoading = false
+    },
+  },
+  computed: {
+    isOwner () {
+      return this.$store.state.auth.authenticated && this.object.actor.full_username === this.$store.state.auth.fullUsername
+    },
+    labels () {
+      return {
+        title: this.$pgettext('*/*/*', 'Library'),
+        visibility: {
+          me: this.$pgettext('Content/Library/Card.Help text', 'Private'),
+          instance: this.$pgettext('Content/Library/Card.Help text', 'Restricted'),
+          everyone: this.$pgettext('Content/Library/Card.Help text', 'Public'),
+        },
+        tooltips: {
+          me: this.$pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'),
+          instance: this.$pgettext('Content/Library/Card.Help text', 'This library is restricted to users on this pod only'),
+          everyone: this.$pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely'),
+        }
+      }
+    },
+    isPlayable () {
+      return this.object.uploads_count > 0 && (
+        this.isOwner ||
+        this.object.privacy_level === 'public' ||
+        (this.object.privacy_level === 'instance' && this.$store.state.auth.authenticated && this.object.actor.domain === this.$store.getters['instance/domain']) ||
+        (this.$store.getters['libraries/follow'](this.object.uuid) || {}).approved === true
+      )
+    },
+  },
+  watch: {
+    id() {
+      this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/views/library/DetailOverview.vue b/front/src/views/library/DetailOverview.vue
new file mode 100644
index 0000000000000000000000000000000000000000..facbfb708d9fb7d168e7536d5fbd497863ad7098
--- /dev/null
+++ b/front/src/views/library/DetailOverview.vue
@@ -0,0 +1,41 @@
+<template>
+  <section>
+    <template v-if="$store.getters['ui/layoutVersion'] === 'small'">
+      <rendered-description
+        :content="object.description ? {html: object.description} : null"
+        :update-url="`channels/${object.uuid}/`"
+        :can-update="false"></rendered-description>
+        <div class="ui hidden divider"></div>
+    </template>
+    <artist-widget
+      :key="object.uploads_count"
+      ref="artists"
+      :header="false"
+      :search="true"
+      :controls="false"
+      :filters="{playable: true, ordering: '-creation_date', library: object.uuid}">
+      <empty-state slot="empty-state">
+        <p>
+          <translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate>
+          <translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate>
+        </p>
+      </empty-state>
+    </artist-widget>
+  </section>
+</template>
+
+<script>
+import ArtistWidget from "@/components/audio/artist/Widget"
+
+export default {
+  props: ['object', 'isOwner'],
+  components: {
+    ArtistWidget,
+  },
+  data () {
+    return  {
+      query: ''
+    }
+  }
+}
+</script>
diff --git a/front/src/views/library/DetailTracks.vue b/front/src/views/library/DetailTracks.vue
new file mode 100644
index 0000000000000000000000000000000000000000..0fa1869a9ebcb7a1d6f00f4af0ed0a7df7b693c6
--- /dev/null
+++ b/front/src/views/library/DetailTracks.vue
@@ -0,0 +1,27 @@
+<template>
+  <section>
+    <track-table
+      :key="object.uploads_count"
+      :display-actions="false"
+      :search="true"
+      :filters="{playable: true, library: object.uuid, ordering: '-creation_date'}">
+      <empty-state slot="empty-state">
+        <p>
+          <translate key="1" v-if="isOwner" translate-context="*/*/*">This library is empty, you should upload something in it!</translate>
+          <translate key="2" v-else translate-context="*/*/*">You may need to follow this library to see its content.</translate>
+        </p>
+      </empty-state>
+    </track-table>
+  </section>
+</template>
+
+<script>
+import TrackTable from '@/components/audio/track/Table'
+
+export default {
+  props: ['object', 'isOwner'],
+  components: {
+    TrackTable,
+  },
+}
+</script>
diff --git a/front/src/views/library/Edit.vue b/front/src/views/library/Edit.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e14ffcca0da7be1fc8c33592caa54ce26bb0e052
--- /dev/null
+++ b/front/src/views/library/Edit.vue
@@ -0,0 +1,101 @@
+<template>
+  <section>
+    <library-form :library="object" @updated="$emit('updated')" @deleted="$router.push({name: 'profile.overview', params: {username: $store.state.auth.username}})" />
+    <div class="ui hidden divider"></div>
+    <h2 class="ui header">
+      <translate translate-context="*/*/*">Library contents</translate>
+    </h2>
+    <library-files-table :filters="{library: object.uuid}"></library-files-table>
+
+    <div class="ui hidden divider"></div>
+    <h2 class="ui header">
+      <translate translate-context="Content/Federation/*/Noun">Followers</translate>
+    </h2>
+    <div v-if="isLoadingFollows" :class="['ui', {'active': isLoadingFollows}, 'inverted', 'dimmer']">
+      <div class="ui text loader"><translate translate-context="Content/Library/Paragraph">Loading followers…</translate></div>
+    </div>
+    <table v-else-if="follows && follows.count > 0" class="ui table">
+      <thead>
+        <tr>
+          <th><translate translate-context="Content/Library/Table.Label">User</translate></th>
+          <th><translate translate-context="Content/Library/Table.Label">Date</translate></th>
+          <th><translate translate-context="*/*/*">Status</translate></th>
+          <th><translate translate-context="Content/Library/Table.Label">Action</translate></th>
+        </tr>
+      </thead>
+      <tr v-for="follow in follows.results" :key="follow.fid">
+        <td><actor-link :actor="follow.actor" /></td>
+        <td><human-date :date="follow.creation_date" /></td>
+        <td>
+          <span :class="['ui', 'yellow', 'basic', 'label']" v-if="follow.approved === null">
+            <translate translate-context="Content/Library/Table/Short">Pending approval</translate>
+          </span>
+          <span :class="['ui', 'green', 'basic', 'label']" v-else-if="follow.approved === true">
+            <translate translate-context="Content/Library/Table/Short">Accepted</translate>
+          </span>
+          <span :class="['ui', 'red', 'basic', 'label']" v-else-if="follow.approved === false">
+            <translate translate-context="Content/Library/*/Short">Rejected</translate>
+          </span>
+        </td>
+        <td>
+          <div @click="updateApproved(follow, true)" :class="['ui', 'mini', 'icon', 'labeled', 'green', 'button']" v-if="follow.approved === null || follow.approved === false">
+            <i class="ui check icon"></i> <translate translate-context="Content/Library/Button.Label">Accept</translate>
+          </div>
+          <div @click="updateApproved(follow, false)" :class="['ui', 'mini', 'icon', 'labeled', 'red', 'button']" v-if="follow.approved === null || follow.approved === true">
+            <i class="ui x icon"></i> <translate translate-context="Content/Library/Button.Label">Reject</translate>
+          </div>
+        </td>
+      </tr>
+
+    </table>
+    <p v-else><translate translate-context="Content/Library/Paragraph">Nobody is following this library</translate></p>
+  </section>
+</template>
+
+<script>
+import LibraryFilesTable from "@/views/content/libraries/FilesTable"
+import LibraryForm from "@/views/content/libraries/Form"
+import axios from "axios"
+
+export default {
+  props: ['object'],
+  components: {
+    LibraryForm,
+    LibraryFilesTable
+  },
+  data () {
+    return {
+      isLoadingFollows: false,
+      follows: null
+    }
+  },
+  created() {
+    this.fetchFollows()
+  },
+  methods: {
+    fetchFollows() {
+      let self = this
+      self.isLoadingLibrary = true
+      axios.get(`libraries/${this.object.uuid}/follows/`).then(response => {
+        self.follows = response.data
+        self.isLoadingFollows = false
+      })
+    },
+    updateApproved(follow, value) {
+      let self = this
+      let action
+      if (value) {
+        action = "accept"
+      } else {
+        action = "reject"
+      }
+      axios
+        .post(`federation/follows/library/${follow.uuid}/${action}/`)
+        .then(response => {
+          follow.isLoading = false
+          follow.approved = value
+        })
+    }
+  }
+}
+</script>
diff --git a/front/src/views/library/Upload.vue b/front/src/views/library/Upload.vue
new file mode 100644
index 0000000000000000000000000000000000000000..66442c43a5e0e66c50a5bdf9766398af0bdadd0b
--- /dev/null
+++ b/front/src/views/library/Upload.vue
@@ -0,0 +1,35 @@
+<template>
+  <section>
+    <file-upload ref="fileupload"
+      :default-import-reference="defaultImportReference"
+      :library="object"
+      @uploads-finished="$emit('uploads-finished', $event)" />
+
+  </section>
+</template>
+
+<script>
+
+import FileUpload from '@/components/library/FileUpload'
+
+export default {
+  props: ['object', 'defaultImportReference'],
+  components: {
+    FileUpload,
+  },
+
+  beforeRouteLeave (to, from, next){
+    if (this.$refs.fileupload.hasActiveUploads){
+      const answer = window.confirm('This page is asking you to confirm that you want to leave - data you have entered may not be saved.')
+      if (answer) {
+        next()
+      } else {
+        next(false)
+      }
+    }
+    else{
+      next()
+    }
+  }
+}
+</script>