diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index 9f87a7af3eee635621c8596bf14561387aa53184..bfa360f1009f7adc917f5c9f36d95aea14d1cec0 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -14,12 +14,11 @@ router.register(r"settings", GlobalPreferencesViewSet, base_name="settings")
 router.register(r"activity", activity_views.ActivityViewSet, "activity")
 router.register(r"tags", views.TagViewSet, "tags")
 router.register(r"tracks", views.TrackViewSet, "tracks")
-router.register(r"trackfiles", views.TrackFileViewSet, "trackfiles")
+router.register(r"track-files", views.TrackFileViewSet, "trackfiles")
+router.register(r"libraries", views.LibraryViewSet, "libraries")
+router.register(r"listen", views.ListenViewSet, "listen")
 router.register(r"artists", views.ArtistViewSet, "artists")
 router.register(r"albums", views.AlbumViewSet, "albums")
-router.register(r"import-batches", views.ImportBatchViewSet, "import-batches")
-router.register(r"import-jobs", views.ImportJobViewSet, "import-jobs")
-router.register(r"submit", views.SubmitViewSet, "submit")
 router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
 router.register(
     r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
diff --git a/api/config/routing.py b/api/config/routing.py
index fa25aad0764fc76662c2de3c0edb3b13b4a54820..13a67cd1e4f2e8691713bc64b0db27248249da46 100644
--- a/api/config/routing.py
+++ b/api/config/routing.py
@@ -8,9 +8,7 @@ application = ProtocolTypeRouter(
     {
         # Empty for now (http->django views is added by default)
         "websocket": TokenAuthMiddleware(
-            URLRouter(
-                [url("^api/v1/instance/activity$", consumers.InstanceActivityConsumer)]
-            )
+            URLRouter([url("^api/v1/activity$", consumers.InstanceActivityConsumer)])
         )
     }
 )
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index d952e0d2d46a3e3aa4e5def2a80a73c3b375ed0a..4b1c693c11ba5e13594af85ba5c323be4f4b4303 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -126,7 +126,6 @@ LOCAL_APPS = (
     "funkwhale_api.history",
     "funkwhale_api.playlists",
     "funkwhale_api.providers.audiofile",
-    "funkwhale_api.providers.youtube",
     "funkwhale_api.providers.acoustid",
     "funkwhale_api.subsonic",
 )
@@ -280,7 +279,7 @@ MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media")))
 
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
 MEDIA_URL = env("MEDIA_URL", default="/media/")
-
+FILE_UPLOAD_PERMISSIONS = 0o644
 # URL Configuration
 # ------------------------------------------------------------------------------
 ROOT_URLCONF = "config.urls"
@@ -446,7 +445,7 @@ REST_FRAMEWORK = {
     "DEFAULT_AUTHENTICATION_CLASSES": (
         "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
         "funkwhale_api.common.authentication.BearerTokenHeaderAuth",
-        "rest_framework_jwt.authentication.JSONWebTokenAuthentication",
+        "funkwhale_api.common.authentication.JSONWebTokenAuthentication",
         "rest_framework.authentication.SessionAuthentication",
         "rest_framework.authentication.BasicAuthentication",
     ),
diff --git a/api/config/urls.py b/api/config/urls.py
index 5ffcf211b8588c5e9ec78d21baa11f288ebf9a20..ba3b630d991828aa1be65c5eb524b560ee6a47bf 100644
--- a/api/config/urls.py
+++ b/api/config/urls.py
@@ -2,11 +2,13 @@
 from __future__ import unicode_literals
 
 from django.conf import settings
-from django.conf.urls import include, url
+from django.conf.urls import url
+from django.urls import include, path
 from django.conf.urls.static import static
 from django.contrib import admin
 from django.views import defaults as default_views
 
+
 urlpatterns = [
     # Django Admin, use {% url 'admin:index' %}
     url(settings.ADMIN_URL, admin.site.urls),
@@ -36,4 +38,6 @@ if settings.DEBUG:
     if "debug_toolbar" in settings.INSTALLED_APPS:
         import debug_toolbar
 
-        urlpatterns += [url(r"^__debug__/", include(debug_toolbar.urls))]
+        urlpatterns = [
+            path("api/__debug__/", include(debug_toolbar.urls))
+        ] + urlpatterns
diff --git a/api/funkwhale_api/common/authentication.py b/api/funkwhale_api/common/authentication.py
index 10bf36613f7e7cb99a20e26184dca552cc7eed44..415b84cb247f4e13f0b750c23c0c940129fa9ea2 100644
--- a/api/funkwhale_api/common/authentication.py
+++ b/api/funkwhale_api/common/authentication.py
@@ -56,3 +56,20 @@ class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
 
     def authenticate_header(self, request):
         return '{0} realm="{1}"'.format("Bearer", self.www_authenticate_realm)
+
+    def authenticate(self, request):
+        auth = super().authenticate(request)
+        if auth:
+            if not auth[0].actor:
+                auth[0].create_actor()
+        return auth
+
+
+class JSONWebTokenAuthentication(authentication.JSONWebTokenAuthentication):
+    def authenticate(self, request):
+        auth = super().authenticate(request)
+
+        if auth:
+            if not auth[0].actor:
+                auth[0].create_actor()
+        return auth
diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py
index a009ab5abf4bce698897cbacef0b263a49d5208e..b8106bef49f4c377c7af28f4993038e8a9a03309 100644
--- a/api/funkwhale_api/common/channels.py
+++ b/api/funkwhale_api/common/channels.py
@@ -1,6 +1,25 @@
+import json
+import logging
+
 from asgiref.sync import async_to_sync
 from channels.layers import get_channel_layer
+from django.core.serializers.json import DjangoJSONEncoder
 
+logger = logging.getLogger(__file__)
 channel_layer = get_channel_layer()
-group_send = async_to_sync(channel_layer.group_send)
 group_add = async_to_sync(channel_layer.group_add)
+
+
+def group_send(group, event):
+    # we serialize the payload ourselves and deserialize it to ensure it
+    # works with msgpack. This is dirty, but we'll find a better solution
+    # later
+    s = json.dumps(event, cls=DjangoJSONEncoder)
+    event = json.loads(s)
+    logger.debug(
+        "[channels] Dispatching %s to group %s: %s",
+        event["type"],
+        group,
+        {"type": event["data"]["type"]},
+    )
+    async_to_sync(channel_layer.group_send)(group, event)
diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py
index 47a666f0540fe15a7a472478b9d75516d8706ef4..48c3186384f278be8e1e3480cd4011f73de2a10c 100644
--- a/api/funkwhale_api/common/consumers.py
+++ b/api/funkwhale_api/common/consumers.py
@@ -16,3 +16,5 @@ class JsonAuthConsumer(JsonWebsocketConsumer):
         super().accept()
         for group in self.groups:
             channels.group_add(group, self.channel_name)
+        for group in self.scope["user"].get_channels_groups():
+            channels.group_add(group, self.channel_name)
diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py
index 769fd00e445693247600ae5809d01dd8f83f8c12..c1fd39e619a016708dd629962e8a6411a8e31714 100644
--- a/api/funkwhale_api/common/scripts/__init__.py
+++ b/api/funkwhale_api/common/scripts/__init__.py
@@ -1,6 +1,7 @@
 from . import create_actors
 from . import create_image_variations
 from . import django_permissions_to_user_permissions
+from . import migrate_to_user_libraries
 from . import test
 
 
@@ -8,5 +9,6 @@ __all__ = [
     "create_actors",
     "create_image_variations",
     "django_permissions_to_user_permissions",
+    "migrate_to_user_libraries",
     "test",
 ]
diff --git a/api/funkwhale_api/common/scripts/migrate_to_user_libraries.py b/api/funkwhale_api/common/scripts/migrate_to_user_libraries.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa3b4d4dab3599c7960d688d19aa32ab1dd84a8c
--- /dev/null
+++ b/api/funkwhale_api/common/scripts/migrate_to_user_libraries.py
@@ -0,0 +1,58 @@
+"""
+Mirate instance files to a library #463. For each user that imported music on an
+instance, we will create a "default" library with related files and an instance-level
+visibility.
+
+Files without any import job will be bounded to a "default" library on the first
+superuser account found. This should now happen though.
+"""
+
+from funkwhale_api.music import models
+from funkwhale_api.users.models import User
+
+
+def main(command, **kwargs):
+    importer_ids = set(
+        models.ImportBatch.objects.values_list("submitted_by", flat=True)
+    )
+    importers = User.objects.filter(pk__in=importer_ids).order_by("id").select_related()
+    command.stdout.write(
+        "* {} users imported music on this instance".format(len(importers))
+    )
+    files = models.TrackFile.objects.filter(
+        library__isnull=True, jobs__isnull=False
+    ).distinct()
+    command.stdout.write(
+        "* Reassigning {} files to importers libraries...".format(files.count())
+    )
+    for user in importers:
+        command.stdout.write(
+            "  * Setting up @{}'s 'default' library".format(user.username)
+        )
+        library = user.actor.libraries.get_or_create(actor=user.actor, name="default")[
+            0
+        ]
+        user_files = files.filter(jobs__batch__submitted_by=user)
+        total = user_files.count()
+        command.stdout.write(
+            "    * Reassigning {} files to the user library...".format(total)
+        )
+        user_files.update(library=library)
+
+    files = models.TrackFile.objects.filter(
+        library__isnull=True, jobs__isnull=True
+    ).distinct()
+    command.stdout.write(
+        "* Handling {} files with no import jobs...".format(files.count())
+    )
+
+    user = User.objects.order_by("id").filter(is_superuser=True).first()
+
+    command.stdout.write("  * Setting up @{}'s 'default' library".format(user.username))
+    library = user.actor.libraries.get_or_create(actor=user.actor, name="default")[0]
+    total = files.count()
+    command.stdout.write(
+        "    * Reassigning {} files to the user library...".format(total)
+    )
+    files.update(library=library)
+    command.stdout.write(" * Done!")
diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py
index 161c581025da4c68d33de0277d956ed710f1b4ed..c94b08d0022ef2ac43f2320c94e1f0765fe10dd3 100644
--- a/api/funkwhale_api/common/serializers.py
+++ b/api/funkwhale_api/common/serializers.py
@@ -1,5 +1,70 @@
+import collections
+
 from rest_framework import serializers
 
+from django.core.exceptions import ObjectDoesNotExist
+from django.utils.encoding import smart_text
+from django.utils.translation import ugettext_lazy as _
+
+
+class RelatedField(serializers.RelatedField):
+    default_error_messages = {
+        "does_not_exist": _("Object with {related_field_name}={value} does not exist."),
+        "invalid": _("Invalid value."),
+    }
+
+    def __init__(self, related_field_name, serializer, **kwargs):
+        self.related_field_name = related_field_name
+        self.serializer = serializer
+        self.filters = kwargs.pop("filters", None)
+        kwargs["queryset"] = kwargs.pop(
+            "queryset", self.serializer.Meta.model.objects.all()
+        )
+        super().__init__(**kwargs)
+
+    def get_filters(self, data):
+        filters = {self.related_field_name: data}
+        if self.filters:
+            filters.update(self.filters(self.context))
+        return filters
+
+    def to_internal_value(self, data):
+        try:
+            queryset = self.get_queryset()
+            filters = self.get_filters(data)
+            return queryset.get(**filters)
+        except ObjectDoesNotExist:
+            self.fail(
+                "does_not_exist",
+                related_field_name=self.related_field_name,
+                value=smart_text(data),
+            )
+        except (TypeError, ValueError):
+            self.fail("invalid")
+
+    def to_representation(self, obj):
+        return self.serializer.to_representation(obj)
+
+    def get_choices(self, cutoff=None):
+        queryset = self.get_queryset()
+        if queryset is None:
+            # Ensure that field.choices returns something sensible
+            # even when accessed with a read-only field.
+            return {}
+
+        if cutoff is not None:
+            queryset = queryset[:cutoff]
+
+        return collections.OrderedDict(
+            [
+                (
+                    self.to_representation(item)[self.related_field_name],
+                    self.display_value(item),
+                )
+                for item in queryset
+            ]
+        )
+
 
 class Action(object):
     def __init__(self, name, allow_all=False, qs_filter=None):
@@ -21,6 +86,7 @@ class ActionSerializer(serializers.Serializer):
     objects = serializers.JSONField(required=True)
     filters = serializers.DictField(required=False)
     actions = None
+    pk_field = "pk"
 
     def __init__(self, *args, **kwargs):
         self.actions_by_name = {a.name: a for a in self.actions}
@@ -51,7 +117,9 @@ class ActionSerializer(serializers.Serializer):
         if value == "all":
             return self.queryset.all().order_by("id")
         if type(value) in [list, tuple]:
-            return self.queryset.filter(pk__in=value).order_by("id")
+            return self.queryset.filter(
+                **{"{}__in".format(self.pk_field): value}
+            ).order_by("id")
 
         raise serializers.ValidationError(
             "{} is not a valid value for objects. You must provide either a "
diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py
index dae90ebbdcfeb3719a915f125bc26f76208d431b..7e30c28a6a7283e0c57d049961e617fb60aec3c4 100644
--- a/api/funkwhale_api/favorites/views.py
+++ b/api/funkwhale_api/favorites/views.py
@@ -3,9 +3,12 @@ from rest_framework.decorators import list_route
 from rest_framework.permissions import IsAuthenticatedOrReadOnly
 from rest_framework.response import Response
 
+from django.db.models import Prefetch
+
 from funkwhale_api.activity import record
 from funkwhale_api.common import fields, permissions
 from funkwhale_api.music.models import Track
+from funkwhale_api.music import utils as music_utils
 
 from . import filters, models, serializers
 
@@ -19,11 +22,7 @@ class TrackFavoriteViewSet(
 
     filter_class = filters.TrackFavoriteFilter
     serializer_class = serializers.UserTrackFavoriteSerializer
-    queryset = (
-        models.TrackFavorite.objects.all()
-        .select_related("track__artist", "track__album__artist", "user")
-        .prefetch_related("track__files")
-    )
+    queryset = models.TrackFavorite.objects.all().select_related("user")
     permission_classes = [
         permissions.ConditionalAuthentication,
         permissions.OwnerPermission,
@@ -49,9 +48,14 @@ class TrackFavoriteViewSet(
 
     def get_queryset(self):
         queryset = super().get_queryset()
-        return queryset.filter(
+        queryset = queryset.filter(
             fields.privacy_level_query(self.request.user, "user__privacy_level")
         )
+        tracks = Track.objects.annotate_playable_by_actor(
+            music_utils.get_actor_from_request(self.request)
+        ).select_related("artist", "album__artist")
+        queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
+        return queryset
 
     def perform_create(self, serializer):
         track = Track.objects.get(pk=serializer.data["track"])
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index 73e83e334534bd211daa90a684a1441b5a10907b..2e740780083d454a32345a9816e14568ff336c58 100644
--- a/api/funkwhale_api/federation/activity.py
+++ b/api/funkwhale_api/federation/activity.py
@@ -1,3 +1,9 @@
+import uuid
+
+from funkwhale_api.common import utils as funkwhale_utils
+
+PUBLIC_ADDRESS = "https://www.w3.org/ns/activitystreams#Public"
+
 ACTIVITY_TYPES = [
     "Accept",
     "Add",
@@ -58,4 +64,145 @@ def accept_follow(follow):
     from . import serializers
 
     serializer = serializers.AcceptFollowSerializer(follow)
-    return deliver(serializer.data, to=[follow.actor.url], on_behalf_of=follow.target)
+    return deliver(serializer.data, to=[follow.actor.fid], on_behalf_of=follow.target)
+
+
+def receive(activity, on_behalf_of):
+    from . import models
+    from . import serializers
+    from . import tasks
+
+    # we ensure the activity has the bare minimum structure before storing
+    # it in our database
+    serializer = serializers.BaseActivitySerializer(
+        data=activity, context={"actor": on_behalf_of, "local_recipients": True}
+    )
+    serializer.is_valid(raise_exception=True)
+    copy = serializer.save()
+    # we create inbox items for further delivery
+    items = [
+        models.InboxItem(activity=copy, actor=r, type="to")
+        for r in serializer.validated_data["recipients"]["to"]
+        if hasattr(r, "fid")
+    ]
+    items += [
+        models.InboxItem(activity=copy, actor=r, type="cc")
+        for r in serializer.validated_data["recipients"]["cc"]
+        if hasattr(r, "fid")
+    ]
+    models.InboxItem.objects.bulk_create(items)
+    # at this point, we have the activity in database. Even if we crash, it's
+    # okay, as we can retry later
+    tasks.dispatch_inbox.delay(activity_id=copy.pk)
+    return copy
+
+
+class Router:
+    def __init__(self):
+        self.routes = []
+
+    def connect(self, route, handler):
+        self.routes.append((route, handler))
+
+    def register(self, route):
+        def decorator(handler):
+            self.connect(route, handler)
+            return handler
+
+        return decorator
+
+
+class InboxRouter(Router):
+    def dispatch(self, payload, context):
+        """
+        Receives an Activity payload and some context and trigger our
+        business logic
+        """
+        for route, handler in self.routes:
+            if match_route(route, payload):
+                return handler(payload, context=context)
+
+
+class OutboxRouter(Router):
+    def dispatch(self, routing, context):
+        """
+        Receives a routing payload and some business objects in the context
+        and may yield data that should be persisted in the Activity model
+        for further delivery.
+        """
+        from . import models
+        from . import tasks
+
+        for route, handler in self.routes:
+            if match_route(route, routing):
+                activities_data = []
+                for e in handler(context):
+                    # a route can yield zero, one or more activity payloads
+                    if e:
+                        activities_data.append(e)
+
+                inbox_items_by_activity_uuid = {}
+                prepared_activities = []
+                for activity_data in activities_data:
+                    to = activity_data.pop("to", [])
+                    cc = activity_data.pop("cc", [])
+                    a = models.Activity(**activity_data)
+                    a.uuid = uuid.uuid4()
+                    to_items, new_to = prepare_inbox_items(to, "to")
+                    cc_items, new_cc = prepare_inbox_items(cc, "cc")
+                    if not to_items and not cc_items:
+                        continue
+                    inbox_items_by_activity_uuid[str(a.uuid)] = to_items + cc_items
+                    if new_to:
+                        a.payload["to"] = new_to
+                    if new_cc:
+                        a.payload["cc"] = new_cc
+                    prepared_activities.append(a)
+
+                activities = models.Activity.objects.bulk_create(prepared_activities)
+                activities = [a for a in activities if a]
+
+                final_inbox_items = []
+                for a in activities:
+                    try:
+                        prepared_inbox_items = inbox_items_by_activity_uuid[str(a.uuid)]
+                    except KeyError:
+                        continue
+
+                    for ii in prepared_inbox_items:
+                        ii.activity = a
+                        final_inbox_items.append(ii)
+
+                # create all inbox items, in bulk
+                models.InboxItem.objects.bulk_create(final_inbox_items)
+
+                for a in activities:
+                    funkwhale_utils.on_commit(
+                        tasks.dispatch_outbox.delay, activity_id=a.pk
+                    )
+                return activities
+
+
+def match_route(route, payload):
+    for key, value in route.items():
+        if payload.get(key) != value:
+            return False
+
+    return True
+
+
+def prepare_inbox_items(recipient_list, type):
+    from . import models
+
+    items = []
+    new_list = []  # we return a list of actors url instead
+
+    for r in recipient_list:
+        if r != PUBLIC_ADDRESS:
+            item = models.InboxItem(actor=r, type=type)
+            items.append(item)
+            new_list.append(r.fid)
+        else:
+            new_list.append(r)
+
+    return items, new_list
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
index 7fbf815dc50e73bb2c15c577e418650650b5dc68..a91e9a5e91659d89aea66e76a730967f8f845c49 100644
--- a/api/funkwhale_api/federation/actors.py
+++ b/api/funkwhale_api/federation/actors.py
@@ -3,15 +3,11 @@ import logging
 import xml
 
 from django.conf import settings
-from django.db import transaction
 from django.urls import reverse
 from django.utils import timezone
 from rest_framework.exceptions import PermissionDenied
 
 from funkwhale_api.common import preferences, session
-from funkwhale_api.common import utils as funkwhale_utils
-from funkwhale_api.music import models as music_models
-from funkwhale_api.music import tasks as music_tasks
 
 from . import activity, keys, models, serializers, signing, utils
 
@@ -39,9 +35,9 @@ def get_actor_data(actor_url):
         raise ValueError("Invalid actor payload: {}".format(response.text))
 
 
-def get_actor(actor_url):
+def get_actor(fid):
     try:
-        actor = models.Actor.objects.get(url=actor_url)
+        actor = models.Actor.objects.get(fid=fid)
     except models.Actor.DoesNotExist:
         actor = None
     fetch_delta = datetime.timedelta(
@@ -50,7 +46,7 @@ def get_actor(actor_url):
     if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
         # cache is hot, we can return as is
         return actor
-    data = get_actor_data(actor_url)
+    data = get_actor_data(fid)
     serializer = serializers.ActorSerializer(data=data)
     serializer.is_valid(raise_exception=True)
 
@@ -72,7 +68,7 @@ class SystemActor(object):
 
     def get_actor_instance(self):
         try:
-            return models.Actor.objects.get(url=self.get_actor_url())
+            return models.Actor.objects.get(fid=self.get_actor_id())
         except models.Actor.DoesNotExist:
             pass
         private, public = keys.get_key_pair()
@@ -83,7 +79,7 @@ class SystemActor(object):
         args["public_key"] = public.decode("utf-8")
         return models.Actor.objects.create(**args)
 
-    def get_actor_url(self):
+    def get_actor_id(self):
         return utils.full_url(
             reverse("federation:instance-actors-detail", kwargs={"actor": self.id})
         )
@@ -95,7 +91,7 @@ class SystemActor(object):
             "type": "Person",
             "name": name.format(host=settings.FEDERATION_HOSTNAME),
             "manually_approves_followers": True,
-            "url": self.get_actor_url(),
+            "fid": self.get_actor_id(),
             "shared_inbox_url": utils.full_url(
                 reverse("federation:instance-actors-inbox", kwargs={"actor": id})
             ),
@@ -178,91 +174,13 @@ class SystemActor(object):
         if ac["object"]["type"] != "Follow":
             return
 
-        if ac["object"]["actor"] != sender.url:
+        if ac["object"]["actor"] != sender.fid:
             # not the same actor, permission issue
             return
 
         self.handle_undo_follow(ac, sender)
 
 
-class LibraryActor(SystemActor):
-    id = "library"
-    name = "{host}'s library"
-    summary = "Bot account to federate with {host}'s library"
-    additional_attributes = {"manually_approves_followers": True}
-
-    def serialize(self):
-        data = super().serialize()
-        urls = data.setdefault("url", [])
-        urls.append(
-            {
-                "type": "Link",
-                "mediaType": "application/activity+json",
-                "name": "library",
-                "href": utils.full_url(reverse("federation:music:files-list")),
-            }
-        )
-        return data
-
-    @property
-    def manually_approves_followers(self):
-        return preferences.get("federation__music_needs_approval")
-
-    @transaction.atomic
-    def handle_create(self, ac, sender):
-        try:
-            remote_library = models.Library.objects.get(
-                actor=sender, federation_enabled=True
-            )
-        except models.Library.DoesNotExist:
-            logger.info("Skipping import, we're not following %s", sender.url)
-            return
-
-        if ac["object"]["type"] != "Collection":
-            return
-
-        if ac["object"]["totalItems"] <= 0:
-            return
-
-        try:
-            items = ac["object"]["items"]
-        except KeyError:
-            logger.warning("No items in collection!")
-            return
-
-        item_serializers = [
-            serializers.AudioSerializer(data=i, context={"library": remote_library})
-            for i in items
-        ]
-        now = timezone.now()
-        valid_serializers = []
-        for s in item_serializers:
-            if s.is_valid():
-                valid_serializers.append(s)
-            else:
-                logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors)
-
-        lts = []
-        for s in valid_serializers:
-            lts.append(s.save())
-
-        if remote_library.autoimport:
-            batch = music_models.ImportBatch.objects.create(source="federation")
-            for lt in lts:
-                if lt.creation_date < now:
-                    # track was already in the library, we do not trigger
-                    # an import
-                    continue
-                job = music_models.ImportJob.objects.create(
-                    batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url
-                )
-                funkwhale_utils.on_commit(
-                    music_tasks.import_job_run.delay,
-                    import_job_id=job.pk,
-                    use_acoustid=False,
-                )
-
-
 class TestActor(SystemActor):
     id = "test"
     name = "{host}'s test account"
@@ -321,7 +239,7 @@ class TestActor(SystemActor):
                 {},
             ],
             "type": "Create",
-            "actor": test_actor.url,
+            "actor": test_actor.fid,
             "id": "{}/activity".format(reply_url),
             "published": now.isoformat(),
             "to": ac["actor"],
@@ -336,14 +254,14 @@ class TestActor(SystemActor):
                 "sensitive": False,
                 "url": reply_url,
                 "to": [ac["actor"]],
-                "attributedTo": test_actor.url,
+                "attributedTo": test_actor.fid,
                 "cc": [],
                 "attachment": [],
                 "tag": [
                     {
                         "type": "Mention",
                         "href": ac["actor"],
-                        "name": sender.mention_username,
+                        "name": sender.full_username,
                     }
                 ],
             },
@@ -359,7 +277,7 @@ class TestActor(SystemActor):
         )[0]
         activity.deliver(
             serializers.FollowSerializer(follow_back).data,
-            to=[follow_back.target.url],
+            to=[follow_back.target.fid],
             on_behalf_of=follow_back.actor,
         )
 
@@ -373,7 +291,7 @@ class TestActor(SystemActor):
             return
         undo = serializers.UndoFollowSerializer(follow).data
         follow.delete()
-        activity.deliver(undo, to=[sender.url], on_behalf_of=actor)
+        activity.deliver(undo, to=[sender.fid], on_behalf_of=actor)
 
 
-SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()}
+SYSTEM_ACTORS = {"test": TestActor()}
diff --git a/api/funkwhale_api/federation/admin.py b/api/funkwhale_api/federation/admin.py
index a82e9aaf24086e05e3dbb0f3d1fb4ee7e9dd4b6a..4d8af0bcfce2e523e2e4f934439a47d5edce4860 100644
--- a/api/funkwhale_api/federation/admin.py
+++ b/api/funkwhale_api/federation/admin.py
@@ -6,14 +6,14 @@ from . import models
 @admin.register(models.Actor)
 class ActorAdmin(admin.ModelAdmin):
     list_display = [
-        "url",
+        "fid",
         "domain",
         "preferred_username",
         "type",
         "creation_date",
         "last_fetch_date",
     ]
-    search_fields = ["url", "domain", "preferred_username"]
+    search_fields = ["fid", "domain", "preferred_username"]
     list_filter = ["type"]
 
 
@@ -21,14 +21,14 @@ class ActorAdmin(admin.ModelAdmin):
 class FollowAdmin(admin.ModelAdmin):
     list_display = ["actor", "target", "approved", "creation_date"]
     list_filter = ["approved"]
-    search_fields = ["actor__url", "target__url"]
+    search_fields = ["actor__fid", "target__fid"]
     list_select_related = True
 
 
 @admin.register(models.Library)
 class LibraryAdmin(admin.ModelAdmin):
     list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"]
-    search_fields = ["actor__url", "url"]
+    search_fields = ["actor__fid", "url"]
     list_filter = ["federation_enabled", "download_files", "autoimport"]
     list_select_related = True
 
diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8c30db0c6bb96e85f63822a437d1d1b5922e4ec
--- /dev/null
+++ b/api/funkwhale_api/federation/api_serializers.py
@@ -0,0 +1,57 @@
+from rest_framework import serializers
+
+from funkwhale_api.common import serializers as common_serializers
+from funkwhale_api.music import models as music_models
+
+from . import serializers as federation_serializers
+from . import models
+
+
+class NestedLibraryFollowSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.LibraryFollow
+        fields = ["creation_date", "uuid", "fid", "approved", "modification_date"]
+
+
+class LibrarySerializer(serializers.ModelSerializer):
+    actor = federation_serializers.APIActorSerializer()
+    files_count = serializers.SerializerMethodField()
+    follow = serializers.SerializerMethodField()
+
+    class Meta:
+        model = music_models.Library
+        fields = [
+            "fid",
+            "uuid",
+            "actor",
+            "name",
+            "description",
+            "creation_date",
+            "files_count",
+            "privacy_level",
+            "follow",
+        ]
+
+    def get_files_count(self, o):
+        return max(getattr(o, "_files_count", 0), o.files_count)
+
+    def get_follow(self, o):
+        try:
+            return NestedLibraryFollowSerializer(o._follows[0]).data
+        except (AttributeError, IndexError):
+            return None
+
+
+class LibraryFollowSerializer(serializers.ModelSerializer):
+    target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True)
+
+    class Meta:
+        model = models.LibraryFollow
+        fields = ["creation_date", "uuid", "target", "approved"]
+        read_only_fields = ["uuid", "approved", "creation_date"]
+
+    def validate_target(self, v):
+        actor = self.context["actor"]
+        if v.received_follows.filter(actor=actor).exists():
+            raise serializers.ValidationError("You are already following this library")
+        return v
diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py
index 625043bf6cb89598d867661512fd580814b1fd82..831bc6630416645283d14d0fe9a667cb5398c518 100644
--- a/api/funkwhale_api/federation/api_urls.py
+++ b/api/funkwhale_api/federation/api_urls.py
@@ -1,9 +1,9 @@
 from rest_framework import routers
 
-from . import views
+from . import api_views
 
 router = routers.SimpleRouter()
-router.register(r"libraries", views.LibraryViewSet, "libraries")
-router.register(r"library-tracks", views.LibraryTrackViewSet, "library-tracks")
+router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
+router.register(r"libraries", api_views.LibraryViewSet, "libraries")
 
 urlpatterns = router.urls
diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..75ca8960933ad521d9b6a30e1a378aa41231e3a1
--- /dev/null
+++ b/api/funkwhale_api/federation/api_views.py
@@ -0,0 +1,92 @@
+import requests.exceptions
+
+from django.db.models import Count
+
+from rest_framework import decorators
+from rest_framework import mixins
+from rest_framework import permissions
+from rest_framework import response
+from rest_framework import viewsets
+
+from funkwhale_api.music import models as music_models
+
+from . import api_serializers
+from . import filters
+from . import models
+from . import routes
+from . import serializers
+from . import utils
+
+
+class LibraryFollowViewSet(
+    mixins.CreateModelMixin,
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    viewsets.GenericViewSet,
+):
+    lookup_field = "uuid"
+    queryset = (
+        models.LibraryFollow.objects.all()
+        .order_by("-creation_date")
+        .select_related("target__actor", "actor")
+    )
+    serializer_class = api_serializers.LibraryFollowSerializer
+    permission_classes = [permissions.IsAuthenticated]
+    filter_class = filters.LibraryFollowFilter
+    ordering_fields = ("creation_date",)
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        return qs.filter(actor=self.request.user.actor)
+
+    def perform_create(self, serializer):
+        follow = serializer.save(actor=self.request.user.actor)
+        routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        context["actor"] = self.request.user.actor
+        return context
+
+
+class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
+    lookup_field = "uuid"
+    queryset = (
+        music_models.Library.objects.all()
+        .order_by("-creation_date")
+        .select_related("actor")
+        .annotate(_files_count=Count("files"))
+    )
+    serializer_class = api_serializers.LibrarySerializer
+    permission_classes = [permissions.IsAuthenticated]
+    filter_class = filters.LibraryFollowFilter
+    ordering_fields = ("creation_date",)
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        return qs.viewable_by(actor=self.request.user.actor)
+
+    @decorators.list_route(methods=["post"])
+    def scan(self, request, *args, **kwargs):
+        try:
+            fid = request.data["fid"]
+        except KeyError:
+            return response.Response({"fid": ["This field is required"]})
+        try:
+            library = utils.retrieve(
+                fid,
+                queryset=self.queryset,
+                serializer_class=serializers.LibrarySerializer,
+            )
+        except requests.exceptions.RequestException as e:
+            return response.Response(
+                {"detail": "Error while scanning the library: {}".format(str(e))},
+                status=400,
+            )
+        except serializers.serializers.ValidationError as e:
+            return response.Response(
+                {"detail": "Invalid data in remote library: {}".format(str(e))},
+                status=400,
+            )
+        serializer = self.serializer_class(library)
+        return response.Response({"count": 1, "results": [serializer.data]})
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index 4a13842da3b668b7aeed409b042192e238c6f42b..f9cc79e4201cb39a765fb59d4fec7a385e18d62c 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -8,6 +8,7 @@ from django.utils import timezone
 from django.utils.http import http_date
 
 from funkwhale_api.factories import registry
+from funkwhale_api.users import factories as user_factories
 
 from . import keys, models
 
@@ -61,6 +62,10 @@ class LinkFactory(factory.Factory):
         audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"]))
 
 
+def create_user(actor):
+    return user_factories.UserFactory(actor=actor)
+
+
 @registry.register
 class ActorFactory(factory.DjangoModelFactory):
     public_key = None
@@ -68,7 +73,7 @@ class ActorFactory(factory.DjangoModelFactory):
     preferred_username = factory.Faker("user_name")
     summary = factory.Faker("paragraph")
     domain = factory.Faker("domain_name")
-    url = factory.LazyAttribute(
+    fid = factory.LazyAttribute(
         lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username)
     )
     inbox_url = factory.LazyAttribute(
@@ -81,20 +86,34 @@ class ActorFactory(factory.DjangoModelFactory):
     class Meta:
         model = models.Actor
 
-    class Params:
-        local = factory.Trait(
-            domain=factory.LazyAttribute(lambda o: settings.FEDERATION_HOSTNAME)
-        )
-
-    @classmethod
-    def _generate(cls, create, attrs):
-        has_public = attrs.get("public_key") is not None
-        has_private = attrs.get("private_key") is not None
-        if not has_public and not has_private:
+    @factory.post_generation
+    def local(self, create, extracted, **kwargs):
+        if not extracted and not kwargs:
+            return
+        from funkwhale_api.users.factories import UserFactory
+
+        self.domain = settings.FEDERATION_HOSTNAME
+        self.save(update_fields=["domain"])
+        if not create:
+            if extracted and hasattr(extracted, "pk"):
+                extracted.actor = self
+            else:
+                UserFactory.build(actor=self, **kwargs)
+        if extracted and hasattr(extracted, "pk"):
+            extracted.actor = self
+            extracted.save(update_fields=["user"])
+        else:
+            self.user = UserFactory(actor=self, **kwargs)
+
+    @factory.post_generation
+    def keys(self, create, extracted, **kwargs):
+        if not create:
+            # Simple build, do nothing.
+            return
+        if not extracted:
             private, public = keys.get_key_pair()
-            attrs["private_key"] = private.decode("utf-8")
-            attrs["public_key"] = public.decode("utf-8")
-        return super()._generate(create, attrs)
+            self.private_key = private.decode("utf-8")
+            self.public_key = public.decode("utf-8")
 
 
 @registry.register
@@ -110,15 +129,70 @@ class FollowFactory(factory.DjangoModelFactory):
 
 
 @registry.register
-class LibraryFactory(factory.DjangoModelFactory):
+class MusicLibraryFactory(factory.django.DjangoModelFactory):
+    actor = factory.SubFactory(ActorFactory)
+    privacy_level = "me"
+    name = factory.Faker("sentence")
+    description = factory.Faker("sentence")
+    files_count = 0
+
+    class Meta:
+        model = "music.Library"
+
+    @factory.post_generation
+    def fid(self, create, extracted, **kwargs):
+        if not create:
+            # Simple build, do nothing.
+            return
+
+        self.fid = extracted or self.get_federation_id()
+
+    @factory.post_generation
+    def followers_url(self, create, extracted, **kwargs):
+        if not create:
+            # Simple build, do nothing.
+            return
+
+        self.followers_url = extracted or self.fid + "/followers"
+
+
+@registry.register
+class LibraryScan(factory.django.DjangoModelFactory):
+    library = factory.SubFactory(MusicLibraryFactory)
+    actor = factory.SubFactory(ActorFactory)
+    total_files = factory.LazyAttribute(lambda o: o.library.files_count)
+
+    class Meta:
+        model = "music.LibraryScan"
+
+
+@registry.register
+class ActivityFactory(factory.django.DjangoModelFactory):
     actor = factory.SubFactory(ActorFactory)
     url = factory.Faker("url")
-    federation_enabled = True
-    download_files = False
-    autoimport = False
+    payload = factory.LazyFunction(lambda: {"type": "Create"})
 
     class Meta:
-        model = models.Library
+        model = "federation.Activity"
+
+
+@registry.register
+class InboxItemFactory(factory.django.DjangoModelFactory):
+    actor = factory.SubFactory(ActorFactory)
+    activity = factory.SubFactory(ActivityFactory)
+    type = "to"
+
+    class Meta:
+        model = "federation.InboxItem"
+
+
+@registry.register
+class LibraryFollowFactory(factory.DjangoModelFactory):
+    target = factory.SubFactory(MusicLibraryFactory)
+    actor = factory.SubFactory(ActorFactory)
+
+    class Meta:
+        model = "federation.LibraryFollow"
 
 
 class ArtistMetadataFactory(factory.Factory):
@@ -161,25 +235,6 @@ class LibraryTrackMetadataFactory(factory.Factory):
         model = dict
 
 
-@registry.register
-class LibraryTrackFactory(factory.DjangoModelFactory):
-    library = factory.SubFactory(LibraryFactory)
-    url = factory.Faker("url")
-    title = factory.Faker("sentence")
-    artist_name = factory.Faker("sentence")
-    album_title = factory.Faker("sentence")
-    audio_url = factory.Faker("url")
-    audio_mimetype = "audio/ogg"
-    metadata = factory.SubFactory(LibraryTrackMetadataFactory)
-    published_date = factory.LazyFunction(timezone.now)
-
-    class Meta:
-        model = models.LibraryTrack
-
-    class Params:
-        with_audio_file = factory.Trait(audio_file=factory.django.FileField())
-
-
 @registry.register(name="federation.Note")
 class NoteFactory(factory.Factory):
     type = "Note"
@@ -192,22 +247,6 @@ class NoteFactory(factory.Factory):
         model = dict
 
 
-@registry.register(name="federation.Activity")
-class ActivityFactory(factory.Factory):
-    type = "Create"
-    id = factory.Faker("url")
-    published = factory.LazyFunction(lambda: timezone.now().isoformat())
-    actor = factory.Faker("url")
-    object = factory.SubFactory(
-        NoteFactory,
-        actor=factory.SelfAttribute("..actor"),
-        published=factory.SelfAttribute("..published"),
-    )
-
-    class Meta:
-        model = dict
-
-
 @registry.register(name="federation.AudioMetadata")
 class AudioMetadataFactory(factory.Factory):
     recording = factory.LazyAttribute(
diff --git a/api/funkwhale_api/federation/filters.py b/api/funkwhale_api/federation/filters.py
index ff7575ba5a006aee54826ed86ea62875dc215e15..658bd411cbe3e420ca043a2d62cc61765e763bbb 100644
--- a/api/funkwhale_api/federation/filters.py
+++ b/api/funkwhale_api/federation/filters.py
@@ -1,68 +1,10 @@
 import django_filters
 
 from funkwhale_api.common import fields
-from funkwhale_api.common import search
 
 from . import models
 
 
-class LibraryFilter(django_filters.FilterSet):
-    approved = django_filters.BooleanFilter("following__approved")
-    q = fields.SearchFilter(search_fields=["actor__domain"])
-
-    class Meta:
-        model = models.Library
-        fields = {
-            "approved": ["exact"],
-            "federation_enabled": ["exact"],
-            "download_files": ["exact"],
-            "autoimport": ["exact"],
-            "tracks_count": ["exact"],
-        }
-
-
-class LibraryTrackFilter(django_filters.FilterSet):
-    library = django_filters.CharFilter("library__uuid")
-    status = django_filters.CharFilter(method="filter_status")
-    q = fields.SmartSearchFilter(
-        config=search.SearchConfig(
-            search_fields={
-                "domain": {"to": "library__actor__domain"},
-                "artist": {"to": "artist_name"},
-                "album": {"to": "album_title"},
-                "title": {"to": "title"},
-            },
-            filter_fields={
-                "domain": {"to": "library__actor__domain"},
-                "artist": {"to": "artist_name__iexact"},
-                "album": {"to": "album_title__iexact"},
-                "title": {"to": "title__iexact"},
-            },
-        )
-    )
-
-    def filter_status(self, queryset, field_name, value):
-        if value == "imported":
-            return queryset.filter(local_track_file__isnull=False)
-        elif value == "not_imported":
-            return queryset.filter(local_track_file__isnull=True).exclude(
-                import_jobs__status="pending"
-            )
-        elif value == "import_pending":
-            return queryset.filter(import_jobs__status="pending")
-        return queryset
-
-    class Meta:
-        model = models.LibraryTrack
-        fields = {
-            "library": ["exact"],
-            "artist_name": ["exact", "icontains"],
-            "title": ["exact", "icontains"],
-            "album_title": ["exact", "icontains"],
-            "audio_mimetype": ["exact", "icontains"],
-        }
-
-
 class FollowFilter(django_filters.FilterSet):
     pending = django_filters.CharFilter(method="filter_pending")
     ordering = django_filters.OrderingFilter(
@@ -84,3 +26,9 @@ class FollowFilter(django_filters.FilterSet):
         if value.lower() in ["true", "1", "yes"]:
             queryset = queryset.filter(approved__isnull=True)
         return queryset
+
+
+class LibraryFollowFilter(django_filters.FilterSet):
+    class Meta:
+        model = models.LibraryFollow
+        fields = ["approved"]
diff --git a/api/funkwhale_api/federation/library.py b/api/funkwhale_api/federation/library.py
index d2ccb19524cac60d52469cc795c556cf960f51b9..997790ed0d3f59117d2ce0344c980d60a524856a 100644
--- a/api/funkwhale_api/federation/library.py
+++ b/api/funkwhale_api/federation/library.py
@@ -71,8 +71,7 @@ def scan_from_account_name(account_name):
     return data
 
 
-def get_library_data(library_url):
-    actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
+def get_library_data(library_url, actor):
     auth = signing.get_auth(actor.private_key, actor.private_key_id)
     try:
         response = session.get_session().get(
@@ -98,8 +97,7 @@ def get_library_data(library_url):
     return serializer.validated_data
 
 
-def get_library_page(library, page_url):
-    actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
+def get_library_page(library, page_url, actor):
     auth = signing.get_auth(actor.private_key, actor.private_key_id)
     response = session.get_session().get(
         page_url,
diff --git a/api/funkwhale_api/federation/migrations/0007_auto_20180807_1748.py b/api/funkwhale_api/federation/migrations/0007_auto_20180807_1748.py
new file mode 100644
index 0000000000000000000000000000000000000000..65bbc4cd8d9f58897b89871f7d242e860ea1e8eb
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0007_auto_20180807_1748.py
@@ -0,0 +1,95 @@
+# Generated by Django 2.0.7 on 2018-08-07 17:48
+
+import django.contrib.postgres.fields.jsonb
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("federation", "0006_auto_20180521_1702")]
+
+    operations = [
+        migrations.CreateModel(
+            name="Activity",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
+                (
+                    "fid",
+                    models.URLField(blank=True, max_length=500, null=True, unique=True),
+                ),
+                ("url", models.URLField(blank=True, max_length=500, null=True)),
+                (
+                    "payload",
+                    django.contrib.postgres.fields.jsonb.JSONField(
+                        default={},
+                        encoder=django.core.serializers.json.DjangoJSONEncoder,
+                        max_length=50000,
+                    ),
+                ),
+                (
+                    "creation_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("delivered", models.NullBooleanField(default=None)),
+                ("delivered_date", models.DateTimeField(blank=True, null=True)),
+            ],
+        ),
+        migrations.CreateModel(
+            name="LibraryFollow",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "fid",
+                    models.URLField(blank=True, max_length=500, null=True, unique=True),
+                ),
+                ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
+                (
+                    "creation_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("modification_date", models.DateTimeField(auto_now=True)),
+                ("approved", models.NullBooleanField(default=None)),
+            ],
+        ),
+        migrations.RenameField("actor", "url", "fid"),
+        migrations.AddField(
+            model_name="actor",
+            name="url",
+            field=models.URLField(blank=True, max_length=500, null=True),
+        ),
+        migrations.AddField(
+            model_name="follow",
+            name="fid",
+            field=models.URLField(blank=True, max_length=500, null=True, unique=True),
+        ),
+        migrations.AddField(
+            model_name="libraryfollow",
+            name="actor",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="library_follows",
+                to="federation.Actor",
+            ),
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/migrations/0008_auto_20180807_1748.py b/api/funkwhale_api/federation/migrations/0008_auto_20180807_1748.py
new file mode 100644
index 0000000000000000000000000000000000000000..f86a1e31cc927068288422be9d85ee95626e35ec
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0008_auto_20180807_1748.py
@@ -0,0 +1,36 @@
+# Generated by Django 2.0.7 on 2018-08-07 17:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("music", "0029_auto_20180807_1748"),
+        ("federation", "0007_auto_20180807_1748"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="libraryfollow",
+            name="target",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="received_follows",
+                to="music.Library",
+            ),
+        ),
+        migrations.AddField(
+            model_name="activity",
+            name="actor",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="activities",
+                to="federation.Actor",
+            ),
+        ),
+        migrations.AlterUniqueTogether(
+            name="libraryfollow", unique_together={("actor", "target")}
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/migrations/0009_auto_20180822_1956.py b/api/funkwhale_api/federation/migrations/0009_auto_20180822_1956.py
new file mode 100644
index 0000000000000000000000000000000000000000..042a861436b5eb459f4271afd9db3e008959720e
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0009_auto_20180822_1956.py
@@ -0,0 +1,44 @@
+# Generated by Django 2.0.8 on 2018-08-22 19:56
+
+import django.contrib.postgres.fields.jsonb
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import funkwhale_api.federation.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("federation", "0008_auto_20180807_1748")]
+
+    operations = [
+        migrations.AddField(
+            model_name="activity",
+            name="recipient",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="inbox_activities",
+                to="federation.Actor",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="activity",
+            name="payload",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                default=funkwhale_api.federation.models.empty_dict,
+                encoder=django.core.serializers.json.DjangoJSONEncoder,
+                max_length=50000,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="librarytrack",
+            name="metadata",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                default=funkwhale_api.federation.models.empty_dict,
+                encoder=django.core.serializers.json.DjangoJSONEncoder,
+                max_length=10000,
+            ),
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/migrations/0010_auto_20180904_2011.py b/api/funkwhale_api/federation/migrations/0010_auto_20180904_2011.py
new file mode 100644
index 0000000000000000000000000000000000000000..62b4c73fb2a0af85ece88b0d90628b812f42d891
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0010_auto_20180904_2011.py
@@ -0,0 +1,74 @@
+# Generated by Django 2.0.8 on 2018-09-04 20:11
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("federation", "0009_auto_20180822_1956")]
+
+    operations = [
+        migrations.CreateModel(
+            name="InboxItem",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("is_delivered", models.BooleanField(default=False)),
+                (
+                    "type",
+                    models.CharField(
+                        choices=[("to", "to"), ("cc", "cc")], max_length=10
+                    ),
+                ),
+                ("last_delivery_date", models.DateTimeField(blank=True, null=True)),
+                ("delivery_attempts", models.PositiveIntegerField(default=0)),
+            ],
+        ),
+        migrations.RemoveField(model_name="activity", name="delivered"),
+        migrations.RemoveField(model_name="activity", name="delivered_date"),
+        migrations.RemoveField(model_name="activity", name="recipient"),
+        migrations.AlterField(
+            model_name="activity",
+            name="actor",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="outbox_activities",
+                to="federation.Actor",
+            ),
+        ),
+        migrations.AddField(
+            model_name="inboxitem",
+            name="activity",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="inbox_items",
+                to="federation.Activity",
+            ),
+        ),
+        migrations.AddField(
+            model_name="inboxitem",
+            name="actor",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="inbox_items",
+                to="federation.Actor",
+            ),
+        ),
+        migrations.AddField(
+            model_name="activity",
+            name="recipients",
+            field=models.ManyToManyField(
+                related_name="inbox_activities",
+                through="federation.InboxItem",
+                to="federation.Actor",
+            ),
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index 17ae0137657415243581afe4fdce57edfd1f5be5..8dbf63916151ad31f8df0f431067dfc7032efda6 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -3,6 +3,7 @@ import uuid
 
 from django.conf import settings
 from django.contrib.postgres.fields import JSONField
+from django.core.exceptions import ObjectDoesNotExist
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 from django.utils import timezone
@@ -11,6 +12,8 @@ from funkwhale_api.common import session
 from funkwhale_api.common import utils as common_utils
 from funkwhale_api.music import utils as music_utils
 
+from . import utils as federation_utils
+
 TYPE_CHOICES = [
     ("Person", "Person"),
     ("Application", "Application"),
@@ -20,15 +23,43 @@ TYPE_CHOICES = [
 ]
 
 
+def empty_dict():
+    return {}
+
+
+class FederationMixin(models.Model):
+    # federation id/url
+    fid = models.URLField(unique=True, max_length=500, db_index=True)
+    url = models.URLField(max_length=500, null=True, blank=True)
+
+    class Meta:
+        abstract = True
+
+
 class ActorQuerySet(models.QuerySet):
     def local(self, include=True):
         return self.exclude(user__isnull=include)
 
+    def with_current_usage(self):
+        qs = self
+        for s in ["pending", "skipped", "errored", "finished"]:
+            qs = qs.annotate(
+                **{
+                    "_usage_{}".format(s): models.Sum(
+                        "libraries__files__size",
+                        filter=models.Q(libraries__files__import_status=s),
+                    )
+                }
+            )
+
+        return qs
+
 
 class Actor(models.Model):
     ap_type = "Actor"
 
-    url = models.URLField(unique=True, max_length=500, db_index=True)
+    fid = models.URLField(unique=True, max_length=500, db_index=True)
+    url = models.URLField(max_length=500, null=True, blank=True)
     outbox_url = models.URLField(max_length=500)
     inbox_url = models.URLField(max_length=500)
     following_url = models.URLField(max_length=500, null=True, blank=True)
@@ -63,11 +94,14 @@ class Actor(models.Model):
 
     @property
     def private_key_id(self):
-        return "{}#main-key".format(self.url)
+        return "{}#main-key".format(self.fid)
 
     @property
-    def mention_username(self):
-        return "@{}@{}".format(self.preferred_username, self.domain)
+    def full_username(self):
+        return "{}@{}".format(self.preferred_username, self.domain)
+
+    def __str__(self):
+        return "{}@{}".format(self.preferred_username, self.domain)
 
     def save(self, **kwargs):
         lowercase_fields = ["domain"]
@@ -104,26 +138,98 @@ class Actor(models.Model):
         follows = self.received_follows.filter(approved=True)
         return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
 
+    def should_autoapprove_follow(self, actor):
+        return False
 
-class Follow(models.Model):
-    ap_type = "Follow"
+    def get_user(self):
+        try:
+            return self.user
+        except ObjectDoesNotExist:
+            return None
+
+    def get_current_usage(self):
+        actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
+        data = {}
+        for s in ["pending", "skipped", "errored", "finished"]:
+            data[s] = getattr(actor, "_usage_{}".format(s)) or 0
+
+        data["total"] = sum(data.values())
+        return data
 
+
+class InboxItemQuerySet(models.QuerySet):
+    def local(self, include=True):
+        return self.exclude(actor__user__isnull=include)
+
+
+class InboxItem(models.Model):
+    actor = models.ForeignKey(
+        Actor, related_name="inbox_items", on_delete=models.CASCADE
+    )
+    activity = models.ForeignKey(
+        "Activity", related_name="inbox_items", on_delete=models.CASCADE
+    )
+    is_delivered = models.BooleanField(default=False)
+    type = models.CharField(max_length=10, choices=[("to", "to"), ("cc", "cc")])
+    last_delivery_date = models.DateTimeField(null=True, blank=True)
+    delivery_attempts = models.PositiveIntegerField(default=0)
+
+    objects = InboxItemQuerySet.as_manager()
+
+
+class Activity(models.Model):
+    actor = models.ForeignKey(
+        Actor, related_name="outbox_activities", on_delete=models.CASCADE
+    )
+    recipients = models.ManyToManyField(
+        Actor, related_name="inbox_activities", through=InboxItem
+    )
     uuid = models.UUIDField(default=uuid.uuid4, unique=True)
+    fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
+    url = models.URLField(max_length=500, null=True, blank=True)
+    payload = JSONField(default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder)
+    creation_date = models.DateTimeField(default=timezone.now)
+
+
+class AbstractFollow(models.Model):
+    ap_type = "Follow"
+    fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
+    uuid = models.UUIDField(default=uuid.uuid4, unique=True)
+    creation_date = models.DateTimeField(default=timezone.now)
+    modification_date = models.DateTimeField(auto_now=True)
+    approved = models.NullBooleanField(default=None)
+
+    class Meta:
+        abstract = True
+
+    def get_federation_id(self):
+        return federation_utils.full_url(
+            "{}#follows/{}".format(self.actor.fid, self.uuid)
+        )
+
+
+class Follow(AbstractFollow):
     actor = models.ForeignKey(
         Actor, related_name="emitted_follows", on_delete=models.CASCADE
     )
     target = models.ForeignKey(
         Actor, related_name="received_follows", on_delete=models.CASCADE
     )
-    creation_date = models.DateTimeField(default=timezone.now)
-    modification_date = models.DateTimeField(auto_now=True)
-    approved = models.NullBooleanField(default=None)
 
     class Meta:
         unique_together = ["actor", "target"]
 
-    def get_federation_url(self):
-        return "{}#follows/{}".format(self.actor.url, self.uuid)
+
+class LibraryFollow(AbstractFollow):
+    actor = models.ForeignKey(
+        Actor, related_name="library_follows", on_delete=models.CASCADE
+    )
+    target = models.ForeignKey(
+        "music.Library", related_name="received_follows", on_delete=models.CASCADE
+    )
+
+    class Meta:
+        unique_together = ["actor", "target"]
 
 
 class Library(models.Model):
@@ -167,7 +273,9 @@ class LibraryTrack(models.Model):
     artist_name = models.CharField(max_length=500)
     album_title = models.CharField(max_length=500)
     title = models.CharField(max_length=500)
-    metadata = JSONField(default={}, max_length=10000, encoder=DjangoJSONEncoder)
+    metadata = JSONField(
+        default=empty_dict, max_length=10000, encoder=DjangoJSONEncoder
+    )
 
     @property
     def mbid(self):
diff --git a/api/funkwhale_api/federation/permissions.py b/api/funkwhale_api/federation/permissions.py
deleted file mode 100644
index a08d57e5f354dc6dd77e8ef3a477b5886a9cbde1..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/federation/permissions.py
+++ /dev/null
@@ -1,19 +0,0 @@
-
-from rest_framework.permissions import BasePermission
-
-from funkwhale_api.common import preferences
-
-from . import actors
-
-
-class LibraryFollower(BasePermission):
-    def has_permission(self, request, view):
-        if not preferences.get("federation__music_needs_approval"):
-            return True
-
-        actor = getattr(request, "actor", None)
-        if actor is None:
-            return False
-
-        library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-        return library.received_follows.filter(approved=True, actor=actor).exists()
diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..d247b3d9931cddfd4e4dda48f3ab81e01a97a73c
--- /dev/null
+++ b/api/funkwhale_api/federation/routes.py
@@ -0,0 +1,78 @@
+import logging
+
+from . import activity
+from . import serializers
+
+logger = logging.getLogger(__name__)
+inbox = activity.InboxRouter()
+outbox = activity.OutboxRouter()
+
+
+def with_recipients(payload, to=[], cc=[]):
+    if to:
+        payload["to"] = to
+    if cc:
+        payload["cc"] = cc
+    return payload
+
+
+@inbox.register({"type": "Follow"})
+def inbox_follow(payload, context):
+    context["recipient"] = [
+        ii.actor for ii in context["inbox_items"] if ii.type == "to"
+    ][0]
+    serializer = serializers.FollowSerializer(data=payload, context=context)
+    if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
+        logger.debug(
+            "Discarding invalid follow from {}: %s",
+            context["actor"].fid,
+            serializer.errors,
+        )
+        return
+
+    autoapprove = serializer.validated_data["object"].should_autoapprove_follow(
+        context["actor"]
+    )
+    follow = serializer.save(approved=autoapprove)
+
+    if autoapprove:
+        activity.accept_follow(follow)
+
+
+@inbox.register({"type": "Accept"})
+def inbox_accept(payload, context):
+    context["recipient"] = [
+        ii.actor for ii in context["inbox_items"] if ii.type == "to"
+    ][0]
+    serializer = serializers.AcceptFollowSerializer(data=payload, context=context)
+    if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
+        logger.debug(
+            "Discarding invalid accept from {}: %s",
+            context["actor"].fid,
+            serializer.errors,
+        )
+        return
+
+    serializer.save()
+
+
+@outbox.register({"type": "Accept"})
+def outbox_accept(context):
+    follow = context["follow"]
+    if follow._meta.label == "federation.LibraryFollow":
+        actor = follow.target.actor
+    else:
+        actor = follow.target
+    payload = serializers.AcceptFollowSerializer(follow, context={"actor": actor}).data
+    yield {"actor": actor, "payload": with_recipients(payload, to=[follow.actor])}
+
+
+@outbox.register({"type": "Follow"})
+def outbox_follow(context):
+    follow = context["follow"]
+    if follow._meta.label == "federation.LibraryFollow":
+        target = follow.target.actor
+    else:
+        target = follow.target
+    payload = serializers.FollowSerializer(follow, context={"actor": follow.actor}).data
+    yield {"actor": follow.actor, "payload": with_recipients(payload, to=[target])}
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 304253aa0716e1d14669a3829395f986ad8f93da..30b5dad4d5abd42332c0a64b2739ec808edab89f 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -4,15 +4,12 @@ import urllib.parse
 
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.paginator import Paginator
-from django.db import transaction
 from rest_framework import serializers
 
-from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.common import utils as funkwhale_utils
 from funkwhale_api.music import models as music_models
-from funkwhale_api.music import tasks as music_tasks
 
-from . import activity, filters, models, utils
+from . import activity, models, utils
 
 AP_CONTEXT = [
     "https://www.w3.org/ns/activitystreams",
@@ -38,7 +35,7 @@ class ActorSerializer(serializers.Serializer):
 
     def to_representation(self, instance):
         ret = {
-            "id": instance.url,
+            "id": instance.fid,
             "outbox": instance.outbox_url,
             "inbox": instance.inbox_url,
             "preferredUsername": instance.preferred_username,
@@ -58,9 +55,9 @@ class ActorSerializer(serializers.Serializer):
         ret["@context"] = AP_CONTEXT
         if instance.public_key:
             ret["publicKey"] = {
-                "owner": instance.url,
+                "owner": instance.fid,
                 "publicKeyPem": instance.public_key,
-                "id": "{}#main-key".format(instance.url),
+                "id": "{}#main-key".format(instance.fid),
             }
         ret["endpoints"] = {}
         if instance.shared_inbox_url:
@@ -78,7 +75,7 @@ class ActorSerializer(serializers.Serializer):
 
     def prepare_missing_fields(self):
         kwargs = {
-            "url": self.validated_data["id"],
+            "fid": self.validated_data["id"],
             "outbox_url": self.validated_data["outbox"],
             "inbox_url": self.validated_data["inbox"],
             "following_url": self.validated_data.get("following"),
@@ -91,7 +88,7 @@ class ActorSerializer(serializers.Serializer):
         maf = self.validated_data.get("manuallyApprovesFollowers")
         if maf is not None:
             kwargs["manually_approves_followers"] = maf
-        domain = urllib.parse.urlparse(kwargs["url"]).netloc
+        domain = urllib.parse.urlparse(kwargs["fid"]).netloc
         kwargs["domain"] = domain
         for endpoint, url in self.initial_data.get("endpoints", {}).items():
             if endpoint == "sharedInbox":
@@ -110,7 +107,7 @@ class ActorSerializer(serializers.Serializer):
     def save(self, **kwargs):
         d = self.prepare_missing_fields()
         d.update(kwargs)
-        return models.Actor.objects.update_or_create(url=d["url"], defaults=d)[0]
+        return models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0]
 
     def validate_summary(self, value):
         if value:
@@ -122,6 +119,7 @@ class APIActorSerializer(serializers.ModelSerializer):
         model = models.Actor
         fields = [
             "id",
+            "fid",
             "url",
             "creation_date",
             "summary",
@@ -131,190 +129,73 @@ class APIActorSerializer(serializers.ModelSerializer):
             "domain",
             "type",
             "manually_approves_followers",
+            "full_username",
         ]
 
 
-class LibraryActorSerializer(ActorSerializer):
-    url = serializers.ListField(child=serializers.JSONField())
-
-    def validate(self, validated_data):
-        try:
-            urls = validated_data["url"]
-        except KeyError:
-            raise serializers.ValidationError("Missing URL field")
-
-        for u in urls:
-            try:
-                if u["name"] != "library":
-                    continue
-                validated_data["library_url"] = u["href"]
-                break
-            except KeyError:
-                continue
-
-        return validated_data
-
-
-class APIFollowSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = models.Follow
-        fields = [
-            "uuid",
-            "actor",
-            "target",
-            "approved",
-            "creation_date",
-            "modification_date",
-        ]
-
-
-class APILibrarySerializer(serializers.ModelSerializer):
-    actor = APIActorSerializer()
-    follow = APIFollowSerializer()
-
-    class Meta:
-        model = models.Library
-
-        read_only_fields = [
-            "actor",
-            "uuid",
-            "url",
-            "tracks_count",
-            "follow",
-            "fetched_date",
-            "modification_date",
-            "creation_date",
-        ]
-        fields = [
-            "autoimport",
-            "federation_enabled",
-            "download_files",
-        ] + read_only_fields
-
-
-class APILibraryScanSerializer(serializers.Serializer):
-    until = serializers.DateTimeField(required=False)
-
-
-class APILibraryFollowUpdateSerializer(serializers.Serializer):
-    follow = serializers.IntegerField()
-    approved = serializers.BooleanField()
-
-    def validate_follow(self, value):
-        from . import actors
+class BaseActivitySerializer(serializers.Serializer):
+    id = serializers.URLField(max_length=500, required=False)
+    type = serializers.CharField(max_length=100)
+    actor = serializers.URLField(max_length=500)
 
-        library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-        qs = models.Follow.objects.filter(pk=value, target=library_actor)
+    def validate_actor(self, v):
+        expected = self.context.get("actor")
+        if expected and expected.fid != v:
+            raise serializers.ValidationError("Invalid actor")
+        if expected:
+            # avoid a DB lookup
+            return expected
         try:
-            return qs.get()
-        except models.Follow.DoesNotExist:
-            raise serializers.ValidationError("Invalid follow")
-
-    def save(self):
-        new_status = self.validated_data["approved"]
-        follow = self.validated_data["follow"]
-        if new_status == follow.approved:
-            return follow
-
-        follow.approved = new_status
-        follow.save(update_fields=["approved", "modification_date"])
-        if new_status:
-            activity.accept_follow(follow)
-        return follow
-
+            return models.Actor.objects.get(fid=v)
+        except models.Actor.DoesNotExist:
+            raise serializers.ValidationError("Actor not found")
 
-class APILibraryCreateSerializer(serializers.ModelSerializer):
-    actor = serializers.URLField(max_length=500)
-    federation_enabled = serializers.BooleanField()
-    uuid = serializers.UUIDField(read_only=True)
+    def create(self, validated_data):
+        return models.Activity.objects.create(
+            fid=validated_data.get("id"),
+            actor=validated_data["actor"],
+            payload=self.initial_data,
+        )
 
-    class Meta:
-        model = models.Library
-        fields = ["uuid", "actor", "autoimport", "federation_enabled", "download_files"]
+    def validate(self, data):
+        data["recipients"] = self.validate_recipients(self.initial_data)
+        return super().validate(data)
 
-    def validate(self, validated_data):
-        from . import actors
-        from . import library
+    def validate_recipients(self, payload):
+        """
+        Ensure we have at least a to/cc field with valid actors
+        """
+        to = payload.get("to", [])
+        cc = payload.get("cc", [])
 
-        actor_url = validated_data["actor"]
-        actor_data = actors.get_actor_data(actor_url)
-        acs = LibraryActorSerializer(data=actor_data)
-        acs.is_valid(raise_exception=True)
-        try:
-            actor = models.Actor.objects.get(url=actor_url)
-        except models.Actor.DoesNotExist:
-            actor = acs.save()
-        library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-        validated_data["follow"] = models.Follow.objects.get_or_create(
-            actor=library_actor, target=actor
-        )[0]
-        if validated_data["follow"].approved is None:
-            funkwhale_utils.on_commit(
-                activity.deliver,
-                FollowSerializer(validated_data["follow"]).data,
-                on_behalf_of=validated_data["follow"].actor,
-                to=[validated_data["follow"].target.url],
+        if not to and not cc:
+            raise serializers.ValidationError(
+                "We cannot handle an activity with no recipient"
             )
 
-        library_data = library.get_library_data(acs.validated_data["library_url"])
-        if "errors" in library_data:
-            # we pass silently because it may means we require permission
-            # before scanning
-            pass
-        validated_data["library"] = library_data
-        validated_data["library"].setdefault("id", acs.validated_data["library_url"])
-        validated_data["actor"] = actor
-        return validated_data
-
-    def create(self, validated_data):
-        library = models.Library.objects.update_or_create(
-            url=validated_data["library"]["id"],
-            defaults={
-                "actor": validated_data["actor"],
-                "follow": validated_data["follow"],
-                "tracks_count": validated_data["library"].get("totalItems"),
-                "federation_enabled": validated_data["federation_enabled"],
-                "autoimport": validated_data["autoimport"],
-                "download_files": validated_data["download_files"],
-            },
-        )[0]
-        return library
+        matching = models.Actor.objects.filter(fid__in=to + cc)
+        if self.context.get("local_recipients", False):
+            matching = matching.local()
 
+        if not len(matching):
+            raise serializers.ValidationError("No matching recipients found")
 
-class APILibraryTrackSerializer(serializers.ModelSerializer):
-    library = APILibrarySerializer()
-    status = serializers.SerializerMethodField()
+        actors_by_fid = {a.fid: a for a in matching}
 
-    class Meta:
-        model = models.LibraryTrack
-        fields = [
-            "id",
-            "url",
-            "audio_url",
-            "audio_mimetype",
-            "creation_date",
-            "modification_date",
-            "fetched_date",
-            "published_date",
-            "metadata",
-            "artist_name",
-            "album_title",
-            "title",
-            "library",
-            "local_track_file",
-            "status",
-        ]
+        def match(recipients, actors):
+            for r in recipients:
+                if r == activity.PUBLIC_ADDRESS:
+                    yield r
+                else:
+                    try:
+                        yield actors[r]
+                    except KeyError:
+                        pass
 
-    def get_status(self, o):
-        try:
-            if o.local_track_file is not None:
-                return "imported"
-        except music_models.TrackFile.DoesNotExist:
-            pass
-        for job in o.import_jobs.all():
-            if job.status == "pending":
-                return "import_pending"
-        return "not_imported"
+        return {
+            "to": list(match(to, actors_by_fid)),
+            "cc": list(match(cc, actors_by_fid)),
+        }
 
 
 class FollowSerializer(serializers.Serializer):
@@ -325,35 +206,61 @@ class FollowSerializer(serializers.Serializer):
 
     def validate_object(self, v):
         expected = self.context.get("follow_target")
-        if expected and expected.url != v:
+        if self.parent:
+            # it's probably an accept, so everything is inverted, the actor
+            # the recipient does not matter
+            recipient = None
+        else:
+            recipient = self.context.get("recipient")
+        if expected and expected.fid != v:
             raise serializers.ValidationError("Invalid target")
         try:
-            return models.Actor.objects.get(url=v)
+            obj = models.Actor.objects.get(fid=v)
+            if recipient and recipient.fid != obj.fid:
+                raise serializers.ValidationError("Invalid target")
+            return obj
         except models.Actor.DoesNotExist:
-            raise serializers.ValidationError("Target not found")
+            pass
+        try:
+            qs = music_models.Library.objects.filter(fid=v)
+            if recipient:
+                qs = qs.filter(actor=recipient)
+            return qs.get()
+        except music_models.Library.DoesNotExist:
+            pass
+
+        raise serializers.ValidationError("Target not found")
 
     def validate_actor(self, v):
         expected = self.context.get("follow_actor")
-        if expected and expected.url != v:
+        if expected and expected.fid != v:
             raise serializers.ValidationError("Invalid actor")
         try:
-            return models.Actor.objects.get(url=v)
+            return models.Actor.objects.get(fid=v)
         except models.Actor.DoesNotExist:
             raise serializers.ValidationError("Actor not found")
 
     def save(self, **kwargs):
-        return models.Follow.objects.get_or_create(
+        target = self.validated_data["object"]
+
+        if target._meta.label == "music.Library":
+            follow_class = models.LibraryFollow
+        else:
+            follow_class = models.Follow
+        defaults = kwargs
+        defaults["fid"] = self.validated_data["id"]
+        return follow_class.objects.update_or_create(
             actor=self.validated_data["actor"],
             target=self.validated_data["object"],
-            **kwargs,  # noqa
+            defaults=defaults,
         )[0]
 
     def to_representation(self, instance):
         return {
             "@context": AP_CONTEXT,
-            "actor": instance.actor.url,
-            "id": instance.get_federation_url(),
-            "object": instance.target.url,
+            "actor": instance.actor.fid,
+            "id": instance.get_federation_id(),
+            "object": instance.target.fid,
             "type": "Follow",
         }
 
@@ -376,50 +283,66 @@ class APIFollowSerializer(serializers.ModelSerializer):
 
 
 class AcceptFollowSerializer(serializers.Serializer):
-    id = serializers.URLField(max_length=500)
+    id = serializers.URLField(max_length=500, required=False)
     actor = serializers.URLField(max_length=500)
     object = FollowSerializer()
     type = serializers.ChoiceField(choices=["Accept"])
 
     def validate_actor(self, v):
-        expected = self.context.get("follow_target")
-        if expected and expected.url != v:
+        expected = self.context.get("actor")
+        if expected and expected.fid != v:
             raise serializers.ValidationError("Invalid actor")
         try:
-            return models.Actor.objects.get(url=v)
+            return models.Actor.objects.get(fid=v)
         except models.Actor.DoesNotExist:
             raise serializers.ValidationError("Actor not found")
 
     def validate(self, validated_data):
-        # we ensure the accept actor actually match the follow target
-        if validated_data["actor"] != validated_data["object"]["object"]:
+        # we ensure the accept actor actually match the follow target / library owner
+        target = validated_data["object"]["object"]
+
+        if target._meta.label == "music.Library":
+            expected = target.actor
+            follow_class = models.LibraryFollow
+        else:
+            expected = target
+            follow_class = models.Follow
+        if validated_data["actor"] != expected:
             raise serializers.ValidationError("Actor mismatch")
         try:
             validated_data["follow"] = (
-                models.Follow.objects.filter(
-                    target=validated_data["actor"],
-                    actor=validated_data["object"]["actor"],
+                follow_class.objects.filter(
+                    target=target, actor=validated_data["object"]["actor"]
                 )
                 .exclude(approved=True)
+                .select_related()
                 .get()
             )
-        except models.Follow.DoesNotExist:
+        except follow_class.DoesNotExist:
             raise serializers.ValidationError("No follow to accept")
         return validated_data
 
     def to_representation(self, instance):
+        if instance.target._meta.label == "music.Library":
+            actor = instance.target.actor
+        else:
+            actor = instance.target
+
         return {
             "@context": AP_CONTEXT,
-            "id": instance.get_federation_url() + "/accept",
+            "id": instance.get_federation_id() + "/accept",
             "type": "Accept",
-            "actor": instance.target.url,
+            "actor": actor.fid,
             "object": FollowSerializer(instance).data,
         }
 
     def save(self):
-        self.validated_data["follow"].approved = True
-        self.validated_data["follow"].save()
-        return self.validated_data["follow"]
+        follow = self.validated_data["follow"]
+        follow.approved = True
+        follow.save()
+        if follow.target._meta.label == "music.Library":
+            follow.target.schedule_scan()
+        return follow
 
 
 class UndoFollowSerializer(serializers.Serializer):
@@ -430,10 +353,10 @@ class UndoFollowSerializer(serializers.Serializer):
 
     def validate_actor(self, v):
         expected = self.context.get("follow_target")
-        if expected and expected.url != v:
+        if expected and expected.fid != v:
             raise serializers.ValidationError("Invalid actor")
         try:
-            return models.Actor.objects.get(url=v)
+            return models.Actor.objects.get(fid=v)
         except models.Actor.DoesNotExist:
             raise serializers.ValidationError("Actor not found")
 
@@ -452,9 +375,9 @@ class UndoFollowSerializer(serializers.Serializer):
     def to_representation(self, instance):
         return {
             "@context": AP_CONTEXT,
-            "id": instance.get_federation_url() + "/undo",
+            "id": instance.get_federation_id() + "/undo",
             "type": "Undo",
-            "actor": instance.actor.url,
+            "actor": instance.actor.fid,
             "object": FollowSerializer(instance).data,
         }
 
@@ -488,9 +411,9 @@ class ActorWebfingerSerializer(serializers.Serializer):
         data = {}
         data["subject"] = "acct:{}".format(instance.webfinger_subject)
         data["links"] = [
-            {"rel": "self", "href": instance.url, "type": "application/activity+json"}
+            {"rel": "self", "href": instance.fid, "type": "application/activity+json"}
         ]
-        data["aliases"] = [instance.url]
+        data["aliases"] = [instance.fid]
         return data
 
 
@@ -519,7 +442,7 @@ class ActivitySerializer(serializers.Serializer):
 
     def validate_actor(self, value):
         request_actor = self.context.get("actor")
-        if request_actor and request_actor.url != value:
+        if request_actor and request_actor.fid != value:
             raise serializers.ValidationError(
                 "The actor making the request do not match" " the activity actor"
             )
@@ -560,6 +483,18 @@ class ObjectSerializer(serializers.Serializer):
 OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
 
 
+def get_additional_fields(data):
+    UNSET = object()
+    additional_fields = {}
+    for field in ["name", "summary"]:
+        v = data.get(field, UNSET)
+        if v == UNSET:
+            continue
+        additional_fields[field] = v
+
+    return additional_fields
+
+
 class PaginatedCollectionSerializer(serializers.Serializer):
     type = serializers.ChoiceField(choices=["Collection"])
     totalItems = serializers.IntegerField(min_value=0)
@@ -575,18 +510,70 @@ class PaginatedCollectionSerializer(serializers.Serializer):
         last = funkwhale_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
         d = {
             "id": conf["id"],
-            "actor": conf["actor"].url,
+            "actor": conf["actor"].fid,
             "totalItems": paginator.count,
-            "type": "Collection",
+            "type": conf.get("type", "Collection"),
             "current": current,
             "first": first,
             "last": last,
         }
+        d.update(get_additional_fields(conf))
         if self.context.get("include_ap_context", True):
             d["@context"] = AP_CONTEXT
         return d
 
 
+class LibrarySerializer(PaginatedCollectionSerializer):
+    type = serializers.ChoiceField(choices=["Library"])
+    name = serializers.CharField()
+    summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
+    audience = serializers.ChoiceField(
+        choices=["", None, "https://www.w3.org/ns/activitystreams#Public"],
+        required=False,
+        allow_null=True,
+        allow_blank=True,
+    )
+
+    def to_representation(self, library):
+        conf = {
+            "id": library.fid,
+            "name": library.name,
+            "summary": library.description,
+            "page_size": 100,
+            "actor": library.actor,
+            "items": library.files.filter(import_status="finished"),
+            "type": "Library",
+        }
+        r = super().to_representation(conf)
+        r["audience"] = (
+            "https://www.w3.org/ns/activitystreams#Public"
+            if library.privacy_level == "public"
+            else ""
+        )
+        return r
+
+    def create(self, validated_data):
+        actor = utils.retrieve(
+            validated_data["actor"],
+            queryset=models.Actor,
+            serializer_class=ActorSerializer,
+        )
+        library, created = music_models.Library.objects.update_or_create(
+            fid=validated_data["id"],
+            actor=actor,
+            defaults={
+                "files_count": validated_data["totalItems"],
+                "name": validated_data["name"],
+                "description": validated_data["summary"],
+                "privacy_level": "everyone"
+                if validated_data["audience"]
+                == "https://www.w3.org/ns/activitystreams#Public"
+                else "me",
+            },
+        )
+        return library
+
+
 class CollectionPageSerializer(serializers.Serializer):
     type = serializers.ChoiceField(choices=["CollectionPage"])
     totalItems = serializers.IntegerField(min_value=0)
@@ -623,7 +610,7 @@ class CollectionPageSerializer(serializers.Serializer):
         d = {
             "id": id,
             "partOf": conf["id"],
-            "actor": conf["actor"].url,
+            "actor": conf["actor"].fid,
             "totalItems": page.paginator.count,
             "type": "CollectionPage",
             "first": first,
@@ -645,7 +632,7 @@ class CollectionPageSerializer(serializers.Serializer):
             d["next"] = funkwhale_utils.set_query_parameter(
                 conf["id"], page=page.next_page_number()
             )
-
+        d.update(get_additional_fields(conf))
         if self.context.get("include_ap_context", True):
             d["@context"] = AP_CONTEXT
         return d
@@ -678,6 +665,7 @@ class AudioMetadataSerializer(serializers.Serializer):
 class AudioSerializer(serializers.Serializer):
     type = serializers.CharField()
     id = serializers.URLField(max_length=500)
+    library = serializers.URLField(max_length=500)
     url = serializers.JSONField()
     published = serializers.DateTimeField()
     updated = serializers.DateTimeField(required=False)
@@ -704,32 +692,40 @@ class AudioSerializer(serializers.Serializer):
 
         return v
 
+    def validate_library(self, v):
+        lb = self.context.get("library")
+        if lb:
+            if lb.fid != v:
+                raise serializers.ValidationError("Invalid library")
+            return lb
+        try:
+            return music_models.Library.objects.get(fid=v)
+        except music_models.Library.DoesNotExist:
+            raise serializers.ValidationError("Invalid library")
+
     def create(self, validated_data):
         defaults = {
-            "audio_mimetype": validated_data["url"]["mediaType"],
-            "audio_url": validated_data["url"]["href"],
-            "metadata": validated_data["metadata"],
-            "artist_name": validated_data["metadata"]["artist"]["name"],
-            "album_title": validated_data["metadata"]["release"]["title"],
-            "title": validated_data["metadata"]["recording"]["title"],
-            "published_date": validated_data["published"],
+            "mimetype": validated_data["url"]["mediaType"],
+            "source": validated_data["url"]["href"],
+            "creation_date": validated_data["published"],
             "modification_date": validated_data.get("updated"),
+            "metadata": self.initial_data,
         }
-        return models.LibraryTrack.objects.get_or_create(
-            library=self.context["library"], url=validated_data["id"], defaults=defaults
-        )[0]
+        tf, created = validated_data["library"].files.update_or_create(
+            fid=validated_data["id"], defaults=defaults
+        )
+        return tf
 
     def to_representation(self, instance):
         track = instance.track
         album = instance.track.album
         artist = instance.track.artist
-
         d = {
             "type": "Audio",
-            "id": instance.get_federation_url(),
+            "id": instance.get_federation_id(),
+            "library": instance.library.get_federation_id(),
             "name": instance.track.full_name,
             "published": instance.creation_date.isoformat(),
-            "updated": instance.modification_date.isoformat(),
             "metadata": {
                 "artist": {
                     "musicbrainz_id": str(artist.mbid) if artist.mbid else None,
@@ -748,12 +744,14 @@ class AudioSerializer(serializers.Serializer):
                 "length": instance.duration,
             },
             "url": {
-                "href": utils.full_url(instance.path),
+                "href": utils.full_url(instance.listen_url),
                 "type": "Link",
                 "mediaType": instance.mimetype,
             },
-            "attributedTo": [self.context["actor"].url],
         }
+        if instance.modification_date:
+            d["updated"] = instance.modification_date.isoformat()
+
         if self.context.get("include_ap_context", True):
             d["@context"] = AP_CONTEXT
         return d
@@ -763,7 +761,7 @@ class CollectionSerializer(serializers.Serializer):
     def to_representation(self, conf):
         d = {
             "id": conf["id"],
-            "actor": conf["actor"].url,
+            "actor": conf["actor"].fid,
             "totalItems": len(conf["items"]),
             "type": "Collection",
             "items": [
@@ -777,27 +775,3 @@ class CollectionSerializer(serializers.Serializer):
         if self.context.get("include_ap_context", True):
             d["@context"] = AP_CONTEXT
         return d
-
-
-class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
-    actions = [common_serializers.Action("import", allow_all=True)]
-    filterset_class = filters.LibraryTrackFilter
-
-    @transaction.atomic
-    def handle_import(self, objects):
-        batch = music_models.ImportBatch.objects.create(
-            source="federation", submitted_by=self.context["submitted_by"]
-        )
-        jobs = []
-        for lt in objects:
-            job = music_models.ImportJob(
-                batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url
-            )
-            jobs.append(job)
-
-        music_models.ImportJob.objects.bulk_create(jobs)
-        funkwhale_utils.on_commit(
-            music_tasks.import_batch_run.delay, import_batch_id=batch.pk
-        )
-
-        return {"batch": {"id": batch.pk}}
diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py
index d1b5b7bd21b4a588008bf531003c25e666cd3313..2d7e37a17065b1d0eb056793f63bcf32dc1c8cf5 100644
--- a/api/funkwhale_api/federation/tasks.py
+++ b/api/funkwhale_api/federation/tasks.py
@@ -1,92 +1,23 @@
 import datetime
-import json
 import logging
 import os
 
 from django.conf import settings
-from django.db.models import Q
+from django.db.models import Q, F
 from django.utils import timezone
 from dynamic_preferences.registries import global_preferences_registry
 from requests.exceptions import RequestException
 
 from funkwhale_api.common import session
+from funkwhale_api.music import models as music_models
 from funkwhale_api.taskapp import celery
 
-from . import actors
-from . import library as lb
 from . import models, signing
+from . import routes
 
 logger = logging.getLogger(__name__)
 
 
-@celery.app.task(
-    name="federation.send",
-    autoretry_for=[RequestException],
-    retry_backoff=30,
-    max_retries=5,
-)
-@celery.require_instance(models.Actor, "actor")
-def send(activity, actor, to):
-    logger.info("Preparing activity delivery to %s", to)
-    auth = signing.get_auth(actor.private_key, actor.private_key_id)
-    for url in to:
-        recipient_actor = actors.get_actor(url)
-        logger.debug("delivering to %s", recipient_actor.inbox_url)
-        logger.debug("activity content: %s", json.dumps(activity))
-        response = session.get_session().post(
-            auth=auth,
-            json=activity,
-            url=recipient_actor.inbox_url,
-            timeout=5,
-            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
-            headers={"Content-Type": "application/activity+json"},
-        )
-        response.raise_for_status()
-        logger.debug("Remote answered with %s", response.status_code)
-
-
-@celery.app.task(
-    name="federation.scan_library",
-    autoretry_for=[RequestException],
-    retry_backoff=30,
-    max_retries=5,
-)
-@celery.require_instance(models.Library, "library")
-def scan_library(library, until=None):
-    if not library.federation_enabled:
-        return
-
-    data = lb.get_library_data(library.url)
-    scan_library_page.delay(library_id=library.id, page_url=data["first"], until=until)
-    library.fetched_date = timezone.now()
-    library.tracks_count = data["totalItems"]
-    library.save(update_fields=["fetched_date", "tracks_count"])
-
-
-@celery.app.task(
-    name="federation.scan_library_page",
-    autoretry_for=[RequestException],
-    retry_backoff=30,
-    max_retries=5,
-)
-@celery.require_instance(models.Library, "library")
-def scan_library_page(library, page_url, until=None):
-    if not library.federation_enabled:
-        return
-
-    data = lb.get_library_page(library, page_url)
-    lts = []
-    for item_serializer in data["items"]:
-        item_date = item_serializer.validated_data["published"]
-        if until and item_date < until:
-            return
-        lts.append(item_serializer.save())
-
-    next_page = data.get("next")
-    if next_page and next_page != page_url:
-        scan_library_page.delay(library_id=library.id, page_url=next_page)
-
-
 @celery.app.task(name="federation.clean_music_cache")
 def clean_music_cache():
     preferences = global_preferences_registry.manager()
@@ -96,23 +27,22 @@ def clean_music_cache():
     limit = timezone.now() - datetime.timedelta(minutes=delay)
 
     candidates = (
-        models.LibraryTrack.objects.filter(
+        music_models.TrackFile.objects.filter(
             Q(audio_file__isnull=False)
-            & (
-                Q(local_track_file__accessed_date__lt=limit)
-                | Q(local_track_file__accessed_date=None)
-            )
+            & (Q(accessed_date__lt=limit) | Q(accessed_date=None))
         )
+        .local(False)
         .exclude(audio_file="")
         .only("audio_file", "id")
+        .order_by("id")
     )
-    for lt in candidates:
-        lt.audio_file.delete()
+    for tf in candidates:
+        tf.audio_file.delete()
 
     # we also delete orphaned files, if any
     storage = models.LibraryTrack._meta.get_field("audio_file").storage
-    files = get_files(storage, "federation_cache")
-    existing = models.LibraryTrack.objects.filter(audio_file__in=files)
+    files = get_files(storage, "federation_cache/tracks")
+    existing = music_models.TrackFile.objects.filter(audio_file__in=files)
     missing = set(files) - set(existing.values_list("audio_file", flat=True))
     for m in missing:
         storage.delete(m)
@@ -130,3 +60,100 @@ def get_files(storage, *parts):
     for dir in dirs:
         files += get_files(storage, *(list(parts) + [dir]))
     return [os.path.join(parts[-1], path) for path in files]
+
+
+@celery.app.task(name="federation.dispatch_inbox")
+@celery.require_instance(models.Activity.objects.select_related(), "activity")
+def dispatch_inbox(activity):
+    """
+    Given an activity instance, triggers our internal delivery logic (follow
+    creation, etc.)
+    """
+
+    try:
+        routes.inbox.dispatch(
+            activity.payload,
+            context={
+                "actor": activity.actor,
+                "inbox_items": list(activity.inbox_items.local().select_related()),
+            },
+        )
+    except Exception:
+        activity.inbox_items.local().update(
+            delivery_attempts=F("delivery_attempts") + 1,
+            last_delivery_date=timezone.now(),
+        )
+        raise
+    else:
+        activity.inbox_items.local().update(
+            delivery_attempts=F("delivery_attempts") + 1,
+            last_delivery_date=timezone.now(),
+            is_delivered=True,
+        )
+
+
+@celery.app.task(name="federation.dispatch_outbox")
+@celery.require_instance(models.Activity.objects.select_related(), "activity")
+def dispatch_outbox(activity):
+    """
+    Deliver a local activity to its recipients
+    """
+    inbox_items = activity.inbox_items.all().select_related("actor")
+    local_recipients_items = [ii for ii in inbox_items if ii.actor.is_local]
+    if local_recipients_items:
+        dispatch_inbox.delay(activity_id=activity.pk)
+    remote_recipients_items = [ii for ii in inbox_items if not ii.actor.is_local]
+
+    shared_inbox_urls = {
+        ii.actor.shared_inbox_url
+        for ii in remote_recipients_items
+        if ii.actor.shared_inbox_url
+    }
+    inbox_urls = {
+        ii.actor.inbox_url
+        for ii in remote_recipients_items
+        if not ii.actor.shared_inbox_url
+    }
+    for url in shared_inbox_urls:
+        deliver_to_remote_inbox.delay(activity_id=activity.pk, shared_inbox_url=url)
+
+    for url in inbox_urls:
+        deliver_to_remote_inbox.delay(activity_id=activity.pk, inbox_url=url)
+
+
+@celery.app.task(
+    name="federation.deliver_to_remote_inbox",
+    autoretry_for=[RequestException],
+    retry_backoff=30,
+    max_retries=5,
+)
+@celery.require_instance(models.Activity.objects.select_related(), "activity")
+def deliver_to_remote_inbox(activity, inbox_url=None, shared_inbox_url=None):
+    url = inbox_url or shared_inbox_url
+    actor = activity.actor
+    inbox_items = activity.inbox_items.filter(is_delivered=False)
+    if inbox_url:
+        inbox_items = inbox_items.filter(actor__inbox_url=inbox_url)
+    else:
+        inbox_items = inbox_items.filter(actor__shared_inbox_url=shared_inbox_url)
+    logger.info("Preparing activity delivery to %s", url)
+    auth = signing.get_auth(actor.private_key, actor.private_key_id)
+    try:
+        response = session.get_session().post(
+            auth=auth,
+            json=activity.payload,
+            url=url,
+            timeout=5,
+            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
+            headers={"Content-Type": "application/activity+json"},
+        )
+        logger.debug("Remote answered with %s", response.status_code)
+        response.raise_for_status()
+    except Exception:
+        inbox_items.update(
+            last_delivery_date=timezone.now(),
+            delivery_attempts=F("delivery_attempts") + 1,
+        )
+        raise
+    else:
+        inbox_items.update(last_delivery_date=timezone.now(), is_delivered=True)
diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py
index 319e37bedbb2ba0ebb5ccd59d545613234e74c55..c83728b26541f1dbbb29f2ae23349ca47f6bd274 100644
--- a/api/funkwhale_api/federation/urls.py
+++ b/api/funkwhale_api/federation/urls.py
@@ -11,7 +11,7 @@ router.register(
 router.register(r"federation/actors", views.ActorViewSet, "actors")
 router.register(r".well-known", views.WellKnownViewSet, "well-known")
 
-music_router.register(r"files", views.MusicFilesViewSet, "files")
+music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
 urlpatterns = router.urls + [
     url("federation/music/", include((music_router.urls, "music"), namespace="music"))
 ]
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
index c87bde6a5a785653f0c40b06c33bc81d216b090d..d02c8bf6874cf83240ab7cbe3a5835b2dab3bdea 100644
--- a/api/funkwhale_api/federation/utils.py
+++ b/api/funkwhale_api/federation/utils.py
@@ -2,6 +2,10 @@ import unicodedata
 import re
 from django.conf import settings
 
+from funkwhale_api.common import session
+
+from . import signing
+
 
 def full_url(path):
     """
@@ -52,3 +56,35 @@ def slugify_username(username):
     )
     value = re.sub(r"[^\w\s-]", "", value).strip()
     return re.sub(r"[-\s]+", "_", value)
+
+
+def retrieve(fid, actor=None, serializer_class=None, queryset=None):
+    if queryset:
+        try:
+            # queryset can also be a Model class
+            existing = queryset.filter(fid=fid).first()
+        except AttributeError:
+            existing = queryset.objects.filter(fid=fid).first()
+        if existing:
+            return existing
+
+    auth = (
+        None if not actor else signing.get_auth(actor.private_key, actor.private_key_id)
+    )
+    response = session.get_session().get(
+        fid,
+        auth=auth,
+        timeout=5,
+        verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
+        headers={
+            "Accept": "application/activity+json",
+            "Content-Type": "application/activity+json",
+        },
+    )
+    response.raise_for_status()
+    data = response.json()
+    if not serializer_class:
+        return data
+    serializer = serializer_class(data=data)
+    serializer.is_valid(raise_exception=True)
+    return serializer.save()
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index ae20ab1d8b2162140c734219c7edc000a0bb0ec8..30fdb9a9f2c46c9d65ace923703cca28cde519b3 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -1,25 +1,20 @@
 from django import forms
 from django.core import paginator
-from django.db import transaction
 from django.http import HttpResponse, Http404
 from django.urls import reverse
-from rest_framework import mixins, response, viewsets
+from rest_framework import exceptions, mixins, response, viewsets
 from rest_framework.decorators import detail_route, list_route
 
 from funkwhale_api.common import preferences
 from funkwhale_api.music import models as music_models
-from funkwhale_api.users.permissions import HasUserPermission
 
 from . import (
+    activity,
     actors,
     authentication,
-    filters,
-    library,
     models,
-    permissions,
     renderers,
     serializers,
-    tasks,
     utils,
     webfinger,
 )
@@ -34,7 +29,6 @@ class FederationMixin(object):
 
 class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
     lookup_field = "preferred_username"
-    lookup_value_regex = ".*"
     authentication_classes = [authentication.SignatureAuthentication]
     permission_classes = []
     renderer_classes = [renderers.ActivityPubRenderer]
@@ -43,6 +37,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
 
     @detail_route(methods=["get", "post"])
     def inbox(self, request, *args, **kwargs):
+        actor = self.get_object()
+        if request.method.lower() == "post" and request.actor is None:
+            raise exceptions.AuthenticationFailed(
+                "You need a valid signature to send an activity"
+            )
+        if request.method.lower() == "post":
+            activity.receive(
+                activity=request.data, on_behalf_of=request.actor, recipient=actor
+            )
         return response.Response({}, status=200)
 
     @detail_route(methods=["get", "post"])
@@ -143,161 +146,64 @@ class WellKnownViewSet(viewsets.GenericViewSet):
         return serializers.ActorWebfingerSerializer(actor).data
 
 
-class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
+def has_library_access(request, library):
+    if library.privacy_level == "everyone":
+        return True
+    if request.user.is_authenticated and request.user.is_superuser:
+        return True
+
+    try:
+        actor = request.actor
+    except AttributeError:
+        return False
+
+    return library.received_follows.filter(actor=actor, approved=True).exists()
+
+
+class MusicLibraryViewSet(
+    FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
+):
     authentication_classes = [authentication.SignatureAuthentication]
-    permission_classes = [permissions.LibraryFollower]
+    permission_classes = []
     renderer_classes = [renderers.ActivityPubRenderer]
+    serializer_class = serializers.PaginatedCollectionSerializer
+    queryset = music_models.Library.objects.all().select_related("actor")
+    lookup_field = "uuid"
 
-    def list(self, request, *args, **kwargs):
+    def retrieve(self, request, *args, **kwargs):
+        lb = self.get_object()
+
+        conf = {
+            "id": lb.get_federation_id(),
+            "actor": lb.actor,
+            "name": lb.name,
+            "summary": lb.description,
+            "items": lb.files.order_by("-creation_date"),
+            "item_serializer": serializers.AudioSerializer,
+        }
         page = request.GET.get("page")
-        library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-        qs = (
-            music_models.TrackFile.objects.order_by("-creation_date")
-            .select_related("track__artist", "track__album__artist")
-            .filter(library_track__isnull=True)
-        )
         if page is None:
-            conf = {
-                "id": utils.full_url(reverse("federation:music:files-list")),
-                "page_size": preferences.get("federation__collection_page_size"),
-                "items": qs,
-                "item_serializer": serializers.AudioSerializer,
-                "actor": library,
-            }
-            serializer = serializers.PaginatedCollectionSerializer(conf)
+            serializer = serializers.LibrarySerializer(lb)
             data = serializer.data
         else:
+            # if actor is requesting a specific page, we ensure library is public
+            # or readable by the actor
+            if not has_library_access(request, lb):
+                raise exceptions.AuthenticationFailed(
+                    "You do not have access to this library"
+                )
             try:
                 page_number = int(page)
             except Exception:
                 return response.Response({"page": ["Invalid page number"]}, status=400)
-            p = paginator.Paginator(
-                qs, preferences.get("federation__collection_page_size")
-            )
+            conf["page_size"] = preferences.get("federation__collection_page_size")
+            p = paginator.Paginator(conf["items"], conf["page_size"])
             try:
                 page = p.page(page_number)
-                conf = {
-                    "id": utils.full_url(reverse("federation:music:files-list")),
-                    "page": page,
-                    "item_serializer": serializers.AudioSerializer,
-                    "actor": library,
-                }
+                conf["page"] = page
                 serializer = serializers.CollectionPageSerializer(conf)
                 data = serializer.data
             except paginator.EmptyPage:
                 return response.Response(status=404)
 
         return response.Response(data)
-
-
-class LibraryViewSet(
-    mixins.RetrieveModelMixin,
-    mixins.UpdateModelMixin,
-    mixins.ListModelMixin,
-    viewsets.GenericViewSet,
-):
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["federation"]
-    queryset = models.Library.objects.all().select_related("actor", "follow")
-    lookup_field = "uuid"
-    filter_class = filters.LibraryFilter
-    serializer_class = serializers.APILibrarySerializer
-    ordering_fields = (
-        "id",
-        "creation_date",
-        "fetched_date",
-        "actor__domain",
-        "tracks_count",
-    )
-
-    @list_route(methods=["get"])
-    def fetch(self, request, *args, **kwargs):
-        account = request.GET.get("account")
-        if not account:
-            return response.Response({"account": "This field is mandatory"}, status=400)
-
-        data = library.scan_from_account_name(account)
-        return response.Response(data)
-
-    @detail_route(methods=["post"])
-    def scan(self, request, *args, **kwargs):
-        library = self.get_object()
-        serializer = serializers.APILibraryScanSerializer(data=request.data)
-        serializer.is_valid(raise_exception=True)
-        result = tasks.scan_library.delay(
-            library_id=library.pk, until=serializer.validated_data.get("until")
-        )
-        return response.Response({"task": result.id})
-
-    @list_route(methods=["get"])
-    def following(self, request, *args, **kwargs):
-        library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-        queryset = (
-            models.Follow.objects.filter(actor=library_actor)
-            .select_related("actor", "target")
-            .order_by("-creation_date")
-        )
-        filterset = filters.FollowFilter(request.GET, queryset=queryset)
-        final_qs = filterset.qs
-        serializer = serializers.APIFollowSerializer(final_qs, many=True)
-        data = {"results": serializer.data, "count": len(final_qs)}
-        return response.Response(data)
-
-    @list_route(methods=["get", "patch"])
-    def followers(self, request, *args, **kwargs):
-        if request.method.lower() == "patch":
-            serializer = serializers.APILibraryFollowUpdateSerializer(data=request.data)
-            serializer.is_valid(raise_exception=True)
-            follow = serializer.save()
-            return response.Response(serializers.APIFollowSerializer(follow).data)
-
-        library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-        queryset = (
-            models.Follow.objects.filter(target=library_actor)
-            .select_related("actor", "target")
-            .order_by("-creation_date")
-        )
-        filterset = filters.FollowFilter(request.GET, queryset=queryset)
-        final_qs = filterset.qs
-        serializer = serializers.APIFollowSerializer(final_qs, many=True)
-        data = {"results": serializer.data, "count": len(final_qs)}
-        return response.Response(data)
-
-    @transaction.atomic
-    def create(self, request, *args, **kwargs):
-        serializer = serializers.APILibraryCreateSerializer(data=request.data)
-        serializer.is_valid(raise_exception=True)
-        serializer.save()
-        return response.Response(serializer.data, status=201)
-
-
-class LibraryTrackViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["federation"]
-    queryset = (
-        models.LibraryTrack.objects.all()
-        .select_related("library__actor", "library__follow", "local_track_file")
-        .prefetch_related("import_jobs")
-    )
-    filter_class = filters.LibraryTrackFilter
-    serializer_class = serializers.APILibraryTrackSerializer
-    ordering_fields = (
-        "id",
-        "artist_name",
-        "title",
-        "album_title",
-        "creation_date",
-        "modification_date",
-        "fetched_date",
-        "published_date",
-    )
-
-    @list_route(methods=["post"])
-    def action(self, request, *args, **kwargs):
-        queryset = models.LibraryTrack.objects.filter(local_track_file__isnull=True)
-        serializer = serializers.LibraryTrackActionSerializer(
-            request.data, queryset=queryset, context={"submitted_by": request.user}
-        )
-        serializer.is_valid(raise_exception=True)
-        result = serializer.save()
-        return response.Response(result, status=200)
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index 6c7ef39914bf9fdbdda9a896d59347ac27eb3666..ec3b4f3c080fabe174a340e3326b7365668f9c9e 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -1,9 +1,12 @@
 from rest_framework import mixins, viewsets
 from rest_framework.permissions import IsAuthenticatedOrReadOnly
 
+from django.db.models import Prefetch
+
 from funkwhale_api.activity import record
 from funkwhale_api.common import fields, permissions
-
+from funkwhale_api.music.models import Track
+from funkwhale_api.music import utils as music_utils
 from . import models, serializers
 
 
@@ -15,11 +18,7 @@ class ListeningViewSet(
 ):
 
     serializer_class = serializers.ListeningSerializer
-    queryset = (
-        models.Listening.objects.all()
-        .select_related("track__artist", "track__album__artist", "user")
-        .prefetch_related("track__files")
-    )
+    queryset = models.Listening.objects.all().select_related("user")
     permission_classes = [
         permissions.ConditionalAuthentication,
         permissions.OwnerPermission,
@@ -39,9 +38,13 @@ class ListeningViewSet(
 
     def get_queryset(self):
         queryset = super().get_queryset()
-        return queryset.filter(
+        queryset = queryset.filter(
             fields.privacy_level_query(self.request.user, "user__privacy_level")
         )
+        tracks = Track.objects.annotate_playable_by_actor(
+            music_utils.get_actor_from_request(self.request)
+        ).select_related("artist", "album__artist")
+        return queryset.prefetch_related(Prefetch("track", queryset=tracks))
 
     def get_serializer_context(self):
         context = super().get_serializer_context()
diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index 8098ef1a2f49ee8b6275351a56faae77eb7e8dd6..b5ce3ef4df4b1f8b0c3c2ccbbe4b0b7fb46135ea 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -18,7 +18,7 @@ class ManageTrackFileFilterSet(filters.FilterSet):
 
     class Meta:
         model = music_models.TrackFile
-        fields = ["q", "track__album", "track__artist", "track", "library_track"]
+        fields = ["q", "track__album", "track__artist", "track"]
 
 
 class ManageUserFilterSet(filters.FilterSet):
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index 42585d6a7ed173b4c56dd17e0a1e973d3757bccc..1605aea878e7c004cc265a343030488e35bebc12 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -59,7 +59,6 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
             "bitrate",
             "size",
             "path",
-            "library_track",
         )
 
 
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index 89d2afe4593f5fe0c34118af9976e43307feaf05..29aed270fe511d7fee7f99a42617543f92d611c5 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -15,7 +15,7 @@ class ManageTrackFileViewSet(
 ):
     queryset = (
         music_models.TrackFile.objects.all()
-        .select_related("track__artist", "track__album__artist", "library_track")
+        .select_related("track__artist", "track__album__artist")
         .order_by("-id")
     )
     serializer_class = serializers.ManageTrackFileSerializer
diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py
index a5775acd68022e7530a586bfd21111da4125a104..26d3e34e8d174f53d2bd27e17aba295afcf9d858 100644
--- a/api/funkwhale_api/music/admin.py
+++ b/api/funkwhale_api/music/admin.py
@@ -65,6 +65,7 @@ class TrackFileAdmin(admin.ModelAdmin):
         "mimetype",
         "size",
         "bitrate",
+        "import_status",
     ]
     list_select_related = ["track"]
     search_fields = [
@@ -74,4 +75,12 @@ class TrackFileAdmin(admin.ModelAdmin):
         "track__album__title",
         "track__artist__name",
     ]
-    list_filter = ["mimetype"]
+    list_filter = ["mimetype", "import_status", "library__privacy_level"]
+
+
+@admin.register(models.Library)
+class LibraryAdmin(admin.ModelAdmin):
+    list_display = ["id", "name", "actor", "uuid", "privacy_level", "creation_date"]
+    list_select_related = True
+    search_fields = ["actor__username", "name", "description"]
+    list_filter = ["privacy_level"]
diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py
index 9bcc4350ffae3e56ddef431b9c33960b9899364a..e65ca151b4ec2ada45a97b5bf007fa066e9f94e9 100644
--- a/api/funkwhale_api/music/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -3,8 +3,8 @@ import os
 import factory
 
 from funkwhale_api.factories import ManyToManyFromList, registry
-from funkwhale_api.federation.factories import LibraryTrackFactory
-from funkwhale_api.users.factories import UserFactory
+from funkwhale_api.federation import factories as federation_factories
+
 
 SAMPLES_PATH = os.path.join(
     os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
@@ -51,6 +51,7 @@ class TrackFactory(factory.django.DjangoModelFactory):
 @registry.register
 class TrackFileFactory(factory.django.DjangoModelFactory):
     track = factory.SubFactory(TrackFactory)
+    library = factory.SubFactory(federation_factories.MusicLibraryFactory)
     audio_file = factory.django.FileField(
         from_path=os.path.join(SAMPLES_PATH, "test.ogg")
     )
@@ -58,67 +59,13 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
     bitrate = None
     size = None
     duration = None
+    mimetype = "audio/ogg"
 
     class Meta:
         model = "music.TrackFile"
 
     class Params:
         in_place = factory.Trait(audio_file=None)
-        federation = factory.Trait(
-            audio_file=None,
-            library_track=factory.SubFactory(LibraryTrackFactory),
-            mimetype=factory.LazyAttribute(lambda o: o.library_track.audio_mimetype),
-            source=factory.LazyAttribute(lambda o: o.library_track.audio_url),
-        )
-
-
-@registry.register
-class ImportBatchFactory(factory.django.DjangoModelFactory):
-    submitted_by = factory.SubFactory(UserFactory)
-
-    class Meta:
-        model = "music.ImportBatch"
-
-    class Params:
-        federation = factory.Trait(submitted_by=None, source="federation")
-        finished = factory.Trait(status="finished")
-
-
-@registry.register
-class ImportJobFactory(factory.django.DjangoModelFactory):
-    batch = factory.SubFactory(ImportBatchFactory)
-    source = factory.Faker("url")
-    mbid = factory.Faker("uuid4")
-    replace_if_duplicate = False
-
-    class Meta:
-        model = "music.ImportJob"
-
-    class Params:
-        federation = factory.Trait(
-            mbid=None,
-            library_track=factory.SubFactory(LibraryTrackFactory),
-            batch=factory.SubFactory(ImportBatchFactory, federation=True),
-        )
-        finished = factory.Trait(
-            status="finished", track_file=factory.SubFactory(TrackFileFactory)
-        )
-        in_place = factory.Trait(status="finished", audio_file=None)
-        with_audio_file = factory.Trait(
-            status="finished",
-            audio_file=factory.django.FileField(
-                from_path=os.path.join(SAMPLES_PATH, "test.ogg")
-            ),
-        )
-
-
-@registry.register(name="music.FileImportJob")
-class FileImportJobFactory(ImportJobFactory):
-    source = "file://"
-    mbid = None
-    audio_file = factory.django.FileField(
-        from_path=os.path.join(SAMPLES_PATH, "test.ogg")
-    )
 
 
 @registry.register
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index 87537b675ed95dcfe0b3cfa48d792a5ddb7063ce..1f330c1a500c76c7f6f2fb4736e7419265f17430 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -1,85 +1,97 @@
-from django.db.models import Count
 from django_filters import rest_framework as filters
 
 from funkwhale_api.common import fields
+from funkwhale_api.common import search
 
 from . import models
+from . import utils
 
 
 class ArtistFilter(filters.FilterSet):
     q = fields.SearchFilter(search_fields=["name"])
-    listenable = filters.BooleanFilter(name="_", method="filter_listenable")
+    playable = filters.BooleanFilter(name="_", method="filter_playable")
 
     class Meta:
         model = models.Artist
         fields = {
             "name": ["exact", "iexact", "startswith", "icontains"],
-            "listenable": "exact",
+            "playable": "exact",
         }
 
-    def filter_listenable(self, queryset, name, value):
-        queryset = queryset.annotate(files_count=Count("albums__tracks__files"))
-        if value:
-            return queryset.filter(files_count__gt=0)
-        else:
-            return queryset.filter(files_count=0)
+    def filter_playable(self, queryset, name, value):
+        actor = utils.get_actor_from_request(self.request)
+        return queryset.playable_by(actor, value)
 
 
 class TrackFilter(filters.FilterSet):
     q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
-    listenable = filters.BooleanFilter(name="_", method="filter_listenable")
+    playable = filters.BooleanFilter(name="_", method="filter_playable")
 
     class Meta:
         model = models.Track
         fields = {
             "title": ["exact", "iexact", "startswith", "icontains"],
-            "listenable": ["exact"],
+            "playable": ["exact"],
             "artist": ["exact"],
             "album": ["exact"],
         }
 
-    def filter_listenable(self, queryset, name, value):
-        queryset = queryset.annotate(files_count=Count("files"))
-        if value:
-            return queryset.filter(files_count__gt=0)
-        else:
-            return queryset.filter(files_count=0)
-
-
-class ImportBatchFilter(filters.FilterSet):
-    q = fields.SearchFilter(search_fields=["submitted_by__username", "source"])
+    def filter_playable(self, queryset, name, value):
+        actor = utils.get_actor_from_request(self.request)
+        return queryset.playable_by(actor, value)
+
+
+class TrackFileFilter(filters.FilterSet):
+    library = filters.CharFilter("library__uuid")
+    track = filters.UUIDFilter("track__uuid")
+    track_artist = filters.UUIDFilter("track__artist__uuid")
+    album_artist = filters.UUIDFilter("track__album__artist__uuid")
+    library = filters.UUIDFilter("library__uuid")
+    playable = filters.BooleanFilter(name="_", method="filter_playable")
+    q = fields.SmartSearchFilter(
+        config=search.SearchConfig(
+            search_fields={
+                "track_artist": {"to": "track__artist__name"},
+                "album_artist": {"to": "track__album__artist__name"},
+                "album": {"to": "track__album__title"},
+                "title": {"to": "track__title"},
+            },
+            filter_fields={
+                "artist": {"to": "track__artist__name__iexact"},
+                "mimetype": {"to": "mimetype"},
+                "album": {"to": "track__album__title__iexact"},
+                "title": {"to": "track__title__iexact"},
+                "status": {"to": "import_status"},
+            },
+        )
+    )
 
     class Meta:
-        model = models.ImportBatch
-        fields = {"status": ["exact"], "source": ["exact"], "submitted_by": ["exact"]}
-
-
-class ImportJobFilter(filters.FilterSet):
-    q = fields.SearchFilter(search_fields=["batch__submitted_by__username", "source"])
-
-    class Meta:
-        model = models.ImportJob
-        fields = {
-            "batch": ["exact"],
-            "batch__status": ["exact"],
-            "batch__source": ["exact"],
-            "batch__submitted_by": ["exact"],
-            "status": ["exact"],
-            "source": ["exact"],
-        }
+        model = models.TrackFile
+        fields = [
+            "playable",
+            "import_status",
+            "mimetype",
+            "track",
+            "track_artist",
+            "album_artist",
+            "library",
+            "import_reference",
+        ]
+
+    def filter_playable(self, queryset, name, value):
+        actor = utils.get_actor_from_request(self.request)
+        return queryset.playable_by(actor, value)
 
 
 class AlbumFilter(filters.FilterSet):
-    listenable = filters.BooleanFilter(name="_", method="filter_listenable")
+    playable = filters.BooleanFilter(name="_", method="filter_playable")
     q = fields.SearchFilter(search_fields=["title", "artist__name" "source"])
 
     class Meta:
         model = models.Album
-        fields = ["listenable", "q", "artist"]
-
-    def filter_listenable(self, queryset, name, value):
-        queryset = queryset.annotate(files_count=Count("tracks__files"))
-        if value:
-            return queryset.filter(files_count__gt=0)
-        else:
-            return queryset.filter(files_count=0)
+        fields = ["playable", "q", "artist"]
+
+    def filter_playable(self, queryset, name, value):
+        actor = utils.get_actor_from_request(self.request)
+        return queryset.playable_by(actor, value)
diff --git a/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py
index d02a17ad2299cd1da67d514e3abc99271ade788a..1878ed020a4bc986131bb036f89746aab21c1b7a 100644
--- a/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py
+++ b/api/funkwhale_api/music/migrations/0028_importjob_replace_if_duplicate.py
@@ -5,14 +5,12 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('music', '0027_auto_20180515_1808'),
-    ]
+    dependencies = [("music", "0027_auto_20180515_1808")]
 
     operations = [
         migrations.AddField(
-            model_name='importjob',
-            name='replace_if_duplicate',
+            model_name="importjob",
+            name="replace_if_duplicate",
             field=models.BooleanField(default=False),
-        ),
+        )
     ]
diff --git a/api/funkwhale_api/music/migrations/0029_auto_20180807_1748.py b/api/funkwhale_api/music/migrations/0029_auto_20180807_1748.py
new file mode 100644
index 0000000000000000000000000000000000000000..9dec6ef35c6e5534ec6b61a7a279cc032aad8159
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0029_auto_20180807_1748.py
@@ -0,0 +1,109 @@
+# Generated by Django 2.0.7 on 2018-08-07 17:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("federation", "0007_auto_20180807_1748"),
+        ("music", "0028_importjob_replace_if_duplicate"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Library",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("fid", models.URLField(db_index=True, max_length=500, unique=True)),
+                ("url", models.URLField(blank=True, max_length=500, null=True)),
+                (
+                    "uuid",
+                    models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
+                ),
+                ("followers_url", models.URLField(max_length=500)),
+                (
+                    "creation_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("name", models.CharField(max_length=100)),
+                (
+                    "description",
+                    models.TextField(blank=True, max_length=5000, null=True),
+                ),
+                (
+                    "privacy_level",
+                    models.CharField(
+                        choices=[
+                            ("me", "Only me"),
+                            ("instance", "Everyone on my instance, and my followers"),
+                            (
+                                "everyone",
+                                "Everyone, including people on other instances",
+                            ),
+                        ],
+                        default="me",
+                        max_length=25,
+                    ),
+                ),
+                (
+                    "actor",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="libraries",
+                        to="federation.Actor",
+                    ),
+                ),
+            ],
+            options={"abstract": False},
+        ),
+        migrations.AddField(
+            model_name="importjob",
+            name="audio_file_size",
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="importbatch",
+            name="import_request",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="import_batches",
+                to="requests.ImportRequest",
+            ),
+        ),
+        migrations.AddField(
+            model_name="importbatch",
+            name="library",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="import_batches",
+                to="music.Library",
+            ),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="library",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="files",
+                to="music.Library",
+            ),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/migrations/0030_auto_20180825_1411.py b/api/funkwhale_api/music/migrations/0030_auto_20180825_1411.py
new file mode 100644
index 0000000000000000000000000000000000000000..8c799dacbb3a2b139f5a5300bda46eb478b9492d
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0030_auto_20180825_1411.py
@@ -0,0 +1,152 @@
+# Generated by Django 2.0.8 on 2018-08-25 14:11
+
+import django.contrib.postgres.fields.jsonb
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import funkwhale_api.music.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("federation", "0009_auto_20180822_1956"),
+        ("music", "0029_auto_20180807_1748"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="LibraryScan",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("total_files", models.PositiveIntegerField(default=0)),
+                ("processed_files", models.PositiveIntegerField(default=0)),
+                ("errored_files", models.PositiveIntegerField(default=0)),
+                ("status", models.CharField(default="pending", max_length=25)),
+                (
+                    "creation_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("modification_date", models.DateTimeField(blank=True, null=True)),
+                (
+                    "actor",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="federation.Actor",
+                    ),
+                ),
+            ],
+        ),
+        migrations.RemoveField(model_name="trackfile", name="library_track"),
+        migrations.AddField(
+            model_name="library",
+            name="files_count",
+            field=models.PositiveIntegerField(default=0),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="fid",
+            field=models.URLField(blank=True, max_length=500, null=True, unique=True),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="import_date",
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="import_details",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                default=funkwhale_api.music.models.empty_dict,
+                encoder=django.core.serializers.json.DjangoJSONEncoder,
+                max_length=50000,
+            ),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="import_metadata",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                default=funkwhale_api.music.models.empty_dict,
+                encoder=django.core.serializers.json.DjangoJSONEncoder,
+                max_length=50000,
+            ),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="import_reference",
+            field=models.CharField(
+                default=funkwhale_api.music.models.get_import_reference, max_length=50
+            ),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="import_status",
+            field=models.CharField(
+                choices=[
+                    ("pending", "Pending"),
+                    ("finished", "Finished"),
+                    ("errored", "Errored"),
+                    ("skipped", "Skipped"),
+                ],
+                default="pending",
+                max_length=25,
+            ),
+        ),
+        migrations.AddField(
+            model_name="trackfile",
+            name="metadata",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                default=funkwhale_api.music.models.empty_dict,
+                encoder=django.core.serializers.json.DjangoJSONEncoder,
+                max_length=50000,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="album",
+            name="release_date",
+            field=models.DateField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name="trackfile",
+            name="audio_file",
+            field=models.FileField(
+                max_length=255, upload_to=funkwhale_api.music.models.get_file_path
+            ),
+        ),
+        migrations.AlterField(
+            model_name="trackfile",
+            name="source",
+            field=models.CharField(blank=True, max_length=500, null=True),
+        ),
+        migrations.AlterField(
+            model_name="trackfile",
+            name="track",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="files",
+                to="music.Track",
+            ),
+        ),
+        migrations.AddField(
+            model_name="libraryscan",
+            name="library",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="scans",
+                to="music.Library",
+            ),
+        ),
+    ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 8e1577ea672576a228e353d0781aaff699775695..6e9bd3c2c1b1c61abbd7f5c814574de7559d0192 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -1,13 +1,14 @@
+import datetime
 import os
-import shutil
 import tempfile
 import uuid
 
 import markdown
 import pendulum
 from django.conf import settings
-from django.core.files import File
+from django.contrib.postgres.fields import JSONField
 from django.core.files.base import ContentFile
+from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 from django.db.models.signals import post_save
 from django.dispatch import receiver
@@ -18,12 +19,18 @@ from taggit.managers import TaggableManager
 from versatileimagefield.fields import VersatileImageField
 from versatileimagefield.image_warmer import VersatileImageFieldWarmer
 
-from funkwhale_api import downloader, musicbrainz
+from funkwhale_api import musicbrainz
+from funkwhale_api.common import fields
+from funkwhale_api.common import utils as common_utils
+from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import utils as federation_utils
-
 from . import importers, metadata, utils
 
 
+def empty_dict():
+    return {}
+
+
 class APIModelMixin(models.Model):
     mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
@@ -89,6 +96,23 @@ class ArtistQuerySet(models.QuerySet):
             models.Prefetch("albums", queryset=Album.objects.with_tracks_count())
         )
 
+    def annotate_playable_by_actor(self, actor):
+        tracks = (
+            Track.objects.playable_by(actor)
+            .filter(artist=models.OuterRef("id"))
+            .order_by("id")
+            .values("id")[:1]
+        )
+        subquery = models.Subquery(tracks)
+        return self.annotate(is_playable_by_actor=subquery)
+
+    def playable_by(self, actor, include=True):
+        tracks = Track.objects.playable_by(actor, include)
+        if include:
+            return self.filter(tracks__in=tracks)
+        else:
+            return self.exclude(tracks__in=tracks)
+
 
 class Artist(APIModelMixin):
     name = models.CharField(max_length=255)
@@ -140,6 +164,23 @@ class AlbumQuerySet(models.QuerySet):
     def with_tracks_count(self):
         return self.annotate(_tracks_count=models.Count("tracks"))
 
+    def annotate_playable_by_actor(self, actor):
+        tracks = (
+            Track.objects.playable_by(actor)
+            .filter(album=models.OuterRef("id"))
+            .order_by("id")
+            .values("id")[:1]
+        )
+        subquery = models.Subquery(tracks)
+        return self.annotate(is_playable_by_actor=subquery)
+
+    def playable_by(self, actor, include=True):
+        tracks = Track.objects.playable_by(actor, include)
+        if include:
+            return self.filter(tracks__in=tracks)
+        else:
+            return self.exclude(tracks__in=tracks)
+
 
 class Album(APIModelMixin):
     title = models.CharField(max_length=255)
@@ -287,11 +328,24 @@ class Lyrics(models.Model):
 
 class TrackQuerySet(models.QuerySet):
     def for_nested_serialization(self):
-        return (
-            self.select_related()
-            .select_related("album__artist", "artist")
-            .prefetch_related("files")
+        return self.select_related().select_related("album__artist", "artist")
+
+    def annotate_playable_by_actor(self, actor):
+        files = (
+            TrackFile.objects.playable_by(actor)
+            .filter(track=models.OuterRef("id"))
+            .order_by("id")
+            .values("id")[:1]
         )
+        subquery = models.Subquery(files)
+        return self.annotate(is_playable_by_actor=subquery)
+
+    def playable_by(self, actor, include=True):
+        files = TrackFile.objects.playable_by(actor, include)
+        if include:
+            return self.filter(files__in=files)
+        else:
+            return self.exclude(files__in=files)
 
 
 def get_artist(release_list):
@@ -423,12 +477,65 @@ class Track(APIModelMixin):
             },
         )
 
+    @property
+    def listen_url(self):
+        return reverse("api:v1:listen-detail", kwargs={"uuid": self.uuid})
+
+
+class TrackFileQuerySet(models.QuerySet):
+    def playable_by(self, actor, include=True):
+        if actor is None:
+            libraries = Library.objects.filter(privacy_level="everyone")
+
+        else:
+            me_query = models.Q(privacy_level="me", actor=actor)
+            instance_query = models.Q(
+                privacy_level="instance", actor__domain=actor.domain
+            )
+            libraries = Library.objects.filter(
+                me_query | instance_query | models.Q(privacy_level="everyone")
+            )
+        if include:
+            return self.filter(library__in=libraries)
+        return self.exclude(library__in=libraries)
+
+    def local(self, include=True):
+        return self.exclude(library__actor__user__isnull=include)
+
+
+TRACK_FILE_IMPORT_STATUS_CHOICES = (
+    ("pending", "Pending"),
+    ("finished", "Finished"),
+    ("errored", "Errored"),
+    ("skipped", "Skipped"),
+)
+
+
+def get_file_path(instance, filename):
+    if instance.library.actor.is_local:
+        return common_utils.ChunkedPath("tracks")(instance, filename)
+    else:
+        # we cache remote tracks in a different directory
+        return common_utils.ChunkedPath("federation_cache/tracks")(instance, filename)
+
+
+def get_import_reference():
+    return str(uuid.uuid4())
+
 
 class TrackFile(models.Model):
+    fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
     uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
-    track = models.ForeignKey(Track, related_name="files", on_delete=models.CASCADE)
-    audio_file = models.FileField(upload_to="tracks/%Y/%m/%d", max_length=255)
-    source = models.URLField(null=True, blank=True, max_length=500)
+    track = models.ForeignKey(
+        Track, related_name="files", on_delete=models.CASCADE, null=True, blank=True
+    )
+    audio_file = models.FileField(upload_to=get_file_path, max_length=255)
+    source = models.CharField(
+        # URL validators are not flexible enough for our file:// and upload:// schemes
+        null=True,
+        blank=True,
+        max_length=500,
+    )
     creation_date = models.DateTimeField(default=timezone.now)
     modification_date = models.DateTimeField(auto_now=True)
     accessed_date = models.DateTimeField(null=True, blank=True)
@@ -437,34 +544,68 @@ class TrackFile(models.Model):
     bitrate = models.IntegerField(null=True, blank=True)
     acoustid_track_id = models.UUIDField(null=True, blank=True)
     mimetype = models.CharField(null=True, blank=True, max_length=200)
+    library = models.ForeignKey(
+        "library", null=True, blank=True, related_name="files", on_delete=models.CASCADE
+    )
 
-    library_track = models.OneToOneField(
-        "federation.LibraryTrack",
-        related_name="local_track_file",
-        on_delete=models.CASCADE,
-        null=True,
-        blank=True,
+    # metadata from federation
+    metadata = JSONField(
+        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder
+    )
+    import_date = models.DateTimeField(null=True, blank=True)
+    # optionnal metadata provided during import
+    import_metadata = JSONField(
+        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder
+    )
+    # status / error details for the import
+    import_status = models.CharField(
+        default="pending", choices=TRACK_FILE_IMPORT_STATUS_CHOICES, max_length=25
+    )
+    # a short reference provided by the client to group multiple files
+    # in the same import
+    import_reference = models.CharField(max_length=50, default=get_import_reference)
+
+    # optionnal metadata about import results (error messages, etc.)
+    import_details = JSONField(
+        default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder
     )
 
-    def download_file(self):
-        # import the track file, since there is not any
-        # we create a tmp dir for the download
-        tmp_dir = tempfile.mkdtemp()
-        data = downloader.download(self.source, target_directory=tmp_dir)
-        self.duration = data.get("duration", None)
-        self.audio_file.save(
-            os.path.basename(data["audio_file_path"]),
-            File(open(data["audio_file_path"], "rb")),
+    objects = TrackFileQuerySet.as_manager()
+
+    def download_audio_from_remote(self, user):
+        from funkwhale_api.common import session
+        from funkwhale_api.federation import signing
+
+        if user.is_authenticated and user.actor:
+            auth = signing.get_auth(user.actor.private_key, user.actor.private_key_id)
+        else:
+            auth = None
+
+        remote_response = session.get_session().get(
+            self.source,
+            auth=auth,
+            stream=True,
+            timeout=20,
+            verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
         )
-        shutil.rmtree(tmp_dir)
-        return self.audio_file
+        with remote_response as r:
+            remote_response.raise_for_status()
+            extension = utils.get_ext_from_type(self.mimetype)
+            title = " - ".join(
+                [self.track.title, self.track.album.title, self.track.artist.name]
+            )
+            filename = "{}.{}".format(title, extension)
+            tmp_file = tempfile.TemporaryFile()
+            for chunk in r.iter_content(chunk_size=512):
+                tmp_file.write(chunk)
+            self.audio_file.save(filename, tmp_file, save=False)
+            self.save(update_fields=["audio_file"])
 
-    def get_federation_url(self):
-        return federation_utils.full_url("/federation/music/file/{}".format(self.uuid))
+    def get_federation_id(self):
+        if self.fid:
+            return self.fid
 
-    @property
-    def path(self):
-        return reverse("api:v1:trackfiles-serve", kwargs={"pk": self.pk})
+        return federation_utils.full_url("/federation/music/file/{}".format(self.uuid))
 
     @property
     def filename(self):
@@ -483,37 +624,30 @@ class TrackFile(models.Model):
         if self.source.startswith("file://"):
             return os.path.getsize(self.source.replace("file://", "", 1))
 
-        if self.library_track and self.library_track.audio_file:
-            return self.library_track.audio_file.size
-
     def get_audio_file(self):
         if self.audio_file:
             return self.audio_file.open()
         if self.source.startswith("file://"):
             return open(self.source.replace("file://", "", 1), "rb")
-        if self.library_track and self.library_track.audio_file:
-            return self.library_track.audio_file.open()
 
-    def set_audio_data(self):
+    def get_audio_data(self):
         audio_file = self.get_audio_file()
-        if audio_file:
-            with audio_file as f:
-                audio_data = utils.get_audio_file_data(f)
-            if not audio_data:
-                return
-            self.duration = int(audio_data["length"])
-            self.bitrate = audio_data["bitrate"]
-            self.size = self.get_file_size()
-        else:
-            lt = self.library_track
-            if lt:
-                self.duration = lt.get_metadata("length")
-                self.size = lt.get_metadata("size")
-                self.bitrate = lt.get_metadata("bitrate")
+        if not audio_file:
+            return
+        audio_data = utils.get_audio_file_data(audio_file)
+        if not audio_data:
+            return
+        return {
+            "duration": int(audio_data["length"]),
+            "bitrate": audio_data["bitrate"],
+            "size": self.get_file_size(),
+        }
 
     def save(self, **kwargs):
         if not self.mimetype and self.audio_file:
             self.mimetype = utils.guess_mimetype(self.audio_file)
+        if not self.size and self.audio_file:
+            self.size = self.audio_file.size
         return super().save(**kwargs)
 
     def get_metadata(self):
@@ -522,6 +656,10 @@ class TrackFile(models.Model):
             return
         return metadata.Metadata(audio_file)
 
+    @property
+    def listen_url(self):
+        return self.track.listen_url + "?file={}".format(self.uuid)
+
 
 IMPORT_STATUS_CHOICES = (
     ("pending", "Pending"),
@@ -559,6 +697,13 @@ class ImportBatch(models.Model):
         blank=True,
         on_delete=models.SET_NULL,
     )
+    library = models.ForeignKey(
+        "Library",
+        related_name="import_batches",
+        null=True,
+        blank=True,
+        on_delete=models.CASCADE,
+    )
 
     class Meta:
         ordering = ["-creation_date"]
@@ -577,7 +722,7 @@ class ImportBatch(models.Model):
 
             tasks.import_batch_notify_followers.delay(import_batch_id=self.pk)
 
-    def get_federation_url(self):
+    def get_federation_id(self):
         return federation_utils.full_url(
             "/federation/music/import/batch/{}".format(self.uuid)
         )
@@ -609,10 +754,100 @@ class ImportJob(models.Model):
         null=True,
         blank=True,
     )
+    audio_file_size = models.IntegerField(null=True, blank=True)
 
     class Meta:
         ordering = ("id",)
 
+    def save(self, **kwargs):
+        if self.audio_file and not self.audio_file_size:
+            self.audio_file_size = self.audio_file.size
+        return super().save(**kwargs)
+
+
+LIBRARY_PRIVACY_LEVEL_CHOICES = [
+    (k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
+]
+
+
+class LibraryQuerySet(models.QuerySet):
+    def with_follows(self, actor):
+        return self.prefetch_related(
+            models.Prefetch(
+                "received_follows",
+                queryset=federation_models.LibraryFollow.objects.filter(actor=actor),
+                to_attr="_follows",
+            )
+        )
+
+
+class Library(federation_models.FederationMixin):
+    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
+    actor = models.ForeignKey(
+        "federation.Actor", related_name="libraries", on_delete=models.CASCADE
+    )
+    followers_url = models.URLField(max_length=500)
+    creation_date = models.DateTimeField(default=timezone.now)
+    name = models.CharField(max_length=100)
+    description = models.TextField(max_length=5000, null=True, blank=True)
+    privacy_level = models.CharField(
+        choices=LIBRARY_PRIVACY_LEVEL_CHOICES, default="me", max_length=25
+    )
+    files_count = models.PositiveIntegerField(default=0)
+    objects = LibraryQuerySet.as_manager()
+
+    def get_federation_id(self):
+        return federation_utils.full_url(
+            reverse("federation:music:libraries-detail", kwargs={"uuid": self.uuid})
+        )
+
+    def save(self, **kwargs):
+        if not self.pk and not self.fid and self.actor.is_local:
+            self.fid = self.get_federation_id()
+            self.followers_url = self.fid + "/followers"
+
+        return super().save(**kwargs)
+
+    def should_autoapprove_follow(self, actor):
+        if self.privacy_level == "everyone":
+            return True
+        if self.privacy_level == "instance" and actor.is_local:
+            return True
+        return False
+
+    def schedule_scan(self):
+        latest_scan = self.scans.order_by("-creation_date").first()
+        delay_between_scans = datetime.timedelta(seconds=3600 * 24)
+        now = timezone.now()
+        if latest_scan and latest_scan.creation_date + delay_between_scans > now:
+            return
+
+        scan = self.scans.create(total_files=self.files_count)
+        from . import tasks
+
+        common_utils.on_commit(tasks.start_library_scan.delay, library_scan_id=scan.pk)
+        return scan
+
+
+SCAN_STATUS = [
+    ("pending", "pending"),
+    ("scanning", "scanning"),
+    ("finished", "finished"),
+]
+
+
+class LibraryScan(models.Model):
+    actor = models.ForeignKey(
+        "federation.Actor", null=True, blank=True, on_delete=models.CASCADE
+    )
+    library = models.ForeignKey(Library, related_name="scans", on_delete=models.CASCADE)
+    total_files = models.PositiveIntegerField(default=0)
+    processed_files = models.PositiveIntegerField(default=0)
+    errored_files = models.PositiveIntegerField(default=0)
+    status = models.CharField(default="pending", max_length=25)
+    creation_date = models.DateTimeField(default=timezone.now)
+    modification_date = models.DateTimeField(null=True, blank=True)
+
 
 @receiver(post_save, sender=ImportJob)
 def update_batch_status(sender, instance, **kwargs):
diff --git a/api/funkwhale_api/music/permissions.py b/api/funkwhale_api/music/permissions.py
deleted file mode 100644
index dc589b5dde4b3dbbd7c720cc73684d1b648497e4..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/music/permissions.py
+++ /dev/null
@@ -1,24 +0,0 @@
-
-from rest_framework.permissions import BasePermission
-
-from funkwhale_api.common import preferences
-from funkwhale_api.federation import actors, models
-
-
-class Listen(BasePermission):
-    def has_permission(self, request, view):
-        if not preferences.get("common__api_authentication_required"):
-            return True
-
-        user = getattr(request, "user", None)
-        if user and user.is_authenticated:
-            return True
-
-        actor = getattr(request, "actor", None)
-        if actor is None:
-            return False
-
-        library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-        return models.Follow.objects.filter(
-            target=library, actor=actor, approved=True
-        ).exists()
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index 0661eb8f4dcd8dadc27c52dacec793d3ba9bab9b..c8300b3bf30dc3ac8ca04ad9e12b987487c7d491 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -1,12 +1,13 @@
-from django.db.models import Q
+from django.db import transaction
 from rest_framework import serializers
 from taggit.models import Tag
 from versatileimagefield.serializers import VersatileImageFieldSerializer
 
 from funkwhale_api.activity import serializers as activity_serializers
-from funkwhale_api.users.serializers import UserBasicSerializer
+from funkwhale_api.common import serializers as common_serializers
+from funkwhale_api.common import utils as common_utils
 
-from . import models, tasks
+from . import filters, models, tasks
 
 
 cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square")
@@ -15,6 +16,7 @@ cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square")
 class ArtistAlbumSerializer(serializers.ModelSerializer):
     tracks_count = serializers.SerializerMethodField()
     cover = cover_field
+    is_playable = serializers.SerializerMethodField()
 
     class Meta:
         model = models.Album
@@ -27,11 +29,18 @@ class ArtistAlbumSerializer(serializers.ModelSerializer):
             "cover",
             "creation_date",
             "tracks_count",
+            "is_playable",
         )
 
     def get_tracks_count(self, o):
         return o._tracks_count
 
+    def get_is_playable(self, obj):
+        try:
+            return bool(obj.is_playable_by_actor)
+        except AttributeError:
+            return None
+
 
 class ArtistWithAlbumsSerializer(serializers.ModelSerializer):
     albums = ArtistAlbumSerializer(many=True, read_only=True)
@@ -41,30 +50,6 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer):
         fields = ("id", "mbid", "name", "creation_date", "albums")
 
 
-class TrackFileSerializer(serializers.ModelSerializer):
-    path = serializers.SerializerMethodField()
-
-    class Meta:
-        model = models.TrackFile
-        fields = (
-            "id",
-            "path",
-            "source",
-            "filename",
-            "mimetype",
-            "track",
-            "duration",
-            "mimetype",
-            "bitrate",
-            "size",
-        )
-        read_only_fields = ["duration", "mimetype", "bitrate", "size"]
-
-    def get_path(self, o):
-        url = o.path
-        return url
-
-
 class ArtistSimpleSerializer(serializers.ModelSerializer):
     class Meta:
         model = models.Artist
@@ -72,8 +57,9 @@ class ArtistSimpleSerializer(serializers.ModelSerializer):
 
 
 class AlbumTrackSerializer(serializers.ModelSerializer):
-    files = TrackFileSerializer(many=True, read_only=True)
     artist = ArtistSimpleSerializer(read_only=True)
+    is_playable = serializers.SerializerMethodField()
+    listen_url = serializers.SerializerMethodField()
 
     class Meta:
         model = models.Track
@@ -84,15 +70,26 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
             "album",
             "artist",
             "creation_date",
-            "files",
             "position",
+            "is_playable",
+            "listen_url",
         )
 
+    def get_is_playable(self, obj):
+        try:
+            return bool(obj.is_playable_by_actor)
+        except AttributeError:
+            return None
+
+    def get_listen_url(self, obj):
+        return obj.listen_url
+
 
 class AlbumSerializer(serializers.ModelSerializer):
     tracks = serializers.SerializerMethodField()
     artist = ArtistSimpleSerializer(read_only=True)
     cover = cover_field
+    is_playable = serializers.SerializerMethodField()
 
     class Meta:
         model = models.Album
@@ -105,6 +102,7 @@ class AlbumSerializer(serializers.ModelSerializer):
             "release_date",
             "cover",
             "creation_date",
+            "is_playable",
         )
 
     def get_tracks(self, o):
@@ -114,6 +112,12 @@ class AlbumSerializer(serializers.ModelSerializer):
         )
         return AlbumTrackSerializer(ordered_tracks, many=True).data
 
+    def get_is_playable(self, obj):
+        try:
+            return any([bool(t.is_playable_by_actor) for t in obj.tracks.all()])
+        except AttributeError:
+            return None
+
 
 class TrackAlbumSerializer(serializers.ModelSerializer):
     artist = ArtistSimpleSerializer(read_only=True)
@@ -133,10 +137,11 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
 
 
 class TrackSerializer(serializers.ModelSerializer):
-    files = TrackFileSerializer(many=True, read_only=True)
     artist = ArtistSimpleSerializer(read_only=True)
     album = TrackAlbumSerializer(read_only=True)
     lyrics = serializers.SerializerMethodField()
+    is_playable = serializers.SerializerMethodField()
+    listen_url = serializers.SerializerMethodField()
 
     class Meta:
         model = models.Track
@@ -147,14 +152,146 @@ class TrackSerializer(serializers.ModelSerializer):
             "album",
             "artist",
             "creation_date",
-            "files",
             "position",
             "lyrics",
+            "is_playable",
+            "listen_url",
         )
 
     def get_lyrics(self, obj):
         return obj.get_lyrics_url()
 
+    def get_listen_url(self, obj):
+        return obj.listen_url
+
+    def get_is_playable(self, obj):
+        try:
+            return bool(obj.is_playable_by_actor)
+        except AttributeError:
+            return None
+
+
+class LibraryForOwnerSerializer(serializers.ModelSerializer):
+    files_count = serializers.SerializerMethodField()
+    size = serializers.SerializerMethodField()
+
+    class Meta:
+        model = models.Library
+        fields = [
+            "uuid",
+            "fid",
+            "name",
+            "description",
+            "privacy_level",
+            "files_count",
+            "size",
+            "creation_date",
+        ]
+        read_only_fields = ["fid", "uuid", "creation_date", "actor"]
+
+    def get_files_count(self, o):
+        return getattr(o, "_files_count", o.files_count)
+
+    def get_size(self, o):
+        return getattr(o, "_size", 0)
+
+
+class TrackFileSerializer(serializers.ModelSerializer):
+    track = TrackSerializer(required=False, allow_null=True)
+    library = common_serializers.RelatedField(
+        "uuid",
+        LibraryForOwnerSerializer(),
+        required=True,
+        filters=lambda context: {"actor": context["user"].actor},
+    )
+
+    class Meta:
+        model = models.TrackFile
+        fields = [
+            "uuid",
+            "filename",
+            "creation_date",
+            "mimetype",
+            "track",
+            "library",
+            "duration",
+            "mimetype",
+            "bitrate",
+            "size",
+            "import_date",
+            "import_status",
+        ]
+
+        read_only_fields = [
+            "uuid",
+            "creation_date",
+            "duration",
+            "mimetype",
+            "bitrate",
+            "size",
+            "track",
+            "import_date",
+            "import_status",
+        ]
+
+
+class TrackFileForOwnerSerializer(TrackFileSerializer):
+    class Meta(TrackFileSerializer.Meta):
+        fields = TrackFileSerializer.Meta.fields + [
+            "import_details",
+            "import_metadata",
+            "import_reference",
+            "metadata",
+            "source",
+            "audio_file",
+        ]
+        write_only_fields = ["audio_file"]
+        read_only_fields = TrackFileSerializer.Meta.read_only_fields + [
+            "import_details",
+            "import_metadata",
+            "metadata",
+        ]
+
+    def to_representation(self, obj):
+        r = super().to_representation(obj)
+        if "audio_file" in r:
+            del r["audio_file"]
+        return r
+
+    def validate(self, validated_data):
+        if "audio_file" in validated_data:
+            self.validate_upload_quota(validated_data["audio_file"])
+
+        return super().validate(validated_data)
+
+    def validate_upload_quota(self, f):
+        quota_status = self.context["user"].get_quota_status()
+        if (f.size / 1000 / 1000) > quota_status["remaining"]:
+            raise serializers.ValidationError("upload_quota_reached")
+
+        return f
+
+
+class TrackFileActionSerializer(common_serializers.ActionSerializer):
+    actions = [
+        common_serializers.Action("delete", allow_all=True),
+        common_serializers.Action("relaunch_import", allow_all=True),
+    ]
+    filterset_class = filters.TrackFileFilter
+    pk_field = "uuid"
+
+    @transaction.atomic
+    def handle_delete(self, objects):
+        return objects.delete()
+
+    @transaction.atomic
+    def handle_relaunch_import(self, objects):
+        qs = objects.exclude(import_status="finished")
+        pks = list(qs.values_list("id", flat=True))
+        qs.update(import_status="pending")
+        for pk in pks:
+            common_utils.on_commit(tasks.import_track_file.delay, track_file_id=pk)
+
 
 class TagSerializer(serializers.ModelSerializer):
     class Meta:
@@ -176,40 +313,6 @@ class LyricsSerializer(serializers.ModelSerializer):
         fields = ("id", "work", "content", "content_rendered")
 
 
-class ImportJobSerializer(serializers.ModelSerializer):
-    track_file = TrackFileSerializer(read_only=True)
-
-    class Meta:
-        model = models.ImportJob
-        fields = ("id", "mbid", "batch", "source", "status", "track_file", "audio_file")
-        read_only_fields = ("status", "track_file")
-
-
-class ImportBatchSerializer(serializers.ModelSerializer):
-    submitted_by = UserBasicSerializer(read_only=True)
-
-    class Meta:
-        model = models.ImportBatch
-        fields = (
-            "id",
-            "submitted_by",
-            "source",
-            "status",
-            "creation_date",
-            "import_request",
-        )
-        read_only_fields = ("creation_date", "submitted_by", "source")
-
-    def to_representation(self, instance):
-        repr = super().to_representation(instance)
-        try:
-            repr["job_count"] = instance.job_count
-        except AttributeError:
-            # Queryset was not annotated
-            pass
-        return repr
-
-
 class TrackActivitySerializer(activity_serializers.ModelSerializer):
     type = serializers.SerializerMethodField()
     name = serializers.CharField(source="title")
@@ -222,33 +325,3 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
 
     def get_type(self, obj):
         return "Audio"
-
-
-class ImportJobRunSerializer(serializers.Serializer):
-    jobs = serializers.PrimaryKeyRelatedField(
-        many=True,
-        queryset=models.ImportJob.objects.filter(status__in=["pending", "errored"]),
-    )
-    batches = serializers.PrimaryKeyRelatedField(
-        many=True, queryset=models.ImportBatch.objects.all()
-    )
-
-    def validate(self, validated_data):
-        jobs = validated_data["jobs"]
-        batches_ids = [b.pk for b in validated_data["batches"]]
-        query = Q(batch__pk__in=batches_ids)
-        query |= Q(pk__in=[j.id for j in jobs])
-        queryset = (
-            models.ImportJob.objects.filter(query)
-            .filter(status__in=["pending", "errored"])
-            .distinct()
-        )
-        validated_data["_jobs"] = queryset
-        return validated_data
-
-    def create(self, validated_data):
-        ids = validated_data["_jobs"].values_list("id", flat=True)
-        validated_data["_jobs"].update(status="pending")
-        for id in ids:
-            tasks.import_job_run.delay(import_job_id=id)
-        return {"jobs": list(ids)}
diff --git a/api/funkwhale_api/music/signals.py b/api/funkwhale_api/music/signals.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a68fe60cd8a4661c368a34c7e42de1ac4337bee
--- /dev/null
+++ b/api/funkwhale_api/music/signals.py
@@ -0,0 +1,5 @@
+import django.dispatch
+
+track_file_import_status_updated = django.dispatch.Signal(
+    providing_args=["old_status", "new_status", "track_file"]
+)
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index 2092b6ee76e0ea8db8dff489b95c14bd6837117f..2f5e140cbaff7d055fdc008b21fce3f194b314b2 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -1,20 +1,27 @@
 import logging
 import os
 
-from django.conf import settings
-from django.core.files.base import ContentFile
+from django.utils import timezone
+from django.db import transaction
+from django.db.models import F
+from django.dispatch import receiver
+
 from musicbrainzngs import ResponseError
+from requests.exceptions import RequestException
 
+from funkwhale_api.common import channels
 from funkwhale_api.common import preferences
 from funkwhale_api.federation import activity, actors
-from funkwhale_api.federation import serializers as federation_serializers
+from funkwhale_api.federation import library as lb
+from funkwhale_api.federation import library as federation_serializers
 from funkwhale_api.providers.acoustid import get_acoustid_client
-from funkwhale_api.providers.audiofile import tasks as audiofile_tasks
 from funkwhale_api.taskapp import celery
 
 from . import lyrics as lyrics_utils
 from . import models
-from . import utils as music_utils
+from . import metadata
+from . import signals
+from . import serializers
 
 logger = logging.getLogger(__name__)
 
@@ -34,8 +41,7 @@ def set_acoustid_on_track_file(track_file):
         return update(result["id"])
 
 
-def import_track_from_remote(library_track):
-    metadata = library_track.metadata
+def import_track_from_remote(metadata):
     try:
         track_mbid = metadata["recording"]["musicbrainz_id"]
         assert track_mbid  # for null/empty values
@@ -52,7 +58,7 @@ def import_track_from_remote(library_track):
     else:
         album, _ = models.Album.get_or_create_from_api(mbid=album_mbid)
         return models.Track.get_or_create_from_title(
-            library_track.title, artist=album.artist, album=album
+            metadata["title"], artist=album.artist, album=album
         )[0]
 
     try:
@@ -63,130 +69,23 @@ def import_track_from_remote(library_track):
     else:
         artist, _ = models.Artist.get_or_create_from_api(mbid=artist_mbid)
         album, _ = models.Album.get_or_create_from_title(
-            library_track.album_title, artist=artist
+            metadata["album_title"], artist=artist
         )
         return models.Track.get_or_create_from_title(
-            library_track.title, artist=artist, album=album
+            metadata["title"], artist=artist, album=album
         )[0]
 
     # worst case scenario, we have absolutely no way to link to a
     # musicbrainz resource, we rely on the name/titles
-    artist, _ = models.Artist.get_or_create_from_name(library_track.artist_name)
+    artist, _ = models.Artist.get_or_create_from_name(metadata["artist_name"])
     album, _ = models.Album.get_or_create_from_title(
-        library_track.album_title, artist=artist
+        metadata["album_title"], artist=artist
     )
     return models.Track.get_or_create_from_title(
-        library_track.title, artist=artist, album=album
+        metadata["title"], artist=artist, album=album
     )[0]
 
 
-def _do_import(import_job, use_acoustid=False):
-    logger.info("[Import Job %s] starting job", import_job.pk)
-    from_file = bool(import_job.audio_file)
-    mbid = import_job.mbid
-    replace = import_job.replace_if_duplicate
-    acoustid_track_id = None
-    duration = None
-    track = None
-    # use_acoustid = use_acoustid and preferences.get('providers_acoustid__api_key')
-    # Acoustid is not reliable, we disable it for now.
-    use_acoustid = False
-    if not mbid and use_acoustid and from_file:
-        # we try to deduce mbid from acoustid
-        client = get_acoustid_client()
-        match = client.get_best_match(import_job.audio_file.path)
-        if match:
-            duration = match["recordings"][0]["duration"]
-            mbid = match["recordings"][0]["id"]
-            acoustid_track_id = match["id"]
-    if mbid:
-        logger.info(
-            "[Import Job %s] importing track from musicbrainz recording %s",
-            import_job.pk,
-            str(mbid),
-        )
-        track, _ = models.Track.get_or_create_from_api(mbid=mbid)
-    elif import_job.audio_file:
-        logger.info(
-            "[Import Job %s] importing track from uploaded track data at %s",
-            import_job.pk,
-            import_job.audio_file.path,
-        )
-        track = audiofile_tasks.import_track_data_from_path(import_job.audio_file.path)
-    elif import_job.library_track:
-        logger.info(
-            "[Import Job %s] importing track from federated library track %s",
-            import_job.pk,
-            import_job.library_track.pk,
-        )
-        track = import_track_from_remote(import_job.library_track)
-    elif import_job.source.startswith("file://"):
-        tf_path = import_job.source.replace("file://", "", 1)
-        logger.info(
-            "[Import Job %s] importing track from local track data at %s",
-            import_job.pk,
-            tf_path,
-        )
-        track = audiofile_tasks.import_track_data_from_path(tf_path)
-    else:
-        raise ValueError(
-            "Not enough data to process import, "
-            "add a mbid, an audio file or a library track"
-        )
-
-    track_file = None
-    if replace:
-        logger.info("[Import Job %s] deleting existing audio file", import_job.pk)
-        track.files.all().delete()
-    elif track.files.count() > 0:
-        logger.info(
-            "[Import Job %s] skipping, we already have a file for this track",
-            import_job.pk,
-        )
-        if import_job.audio_file:
-            import_job.audio_file.delete()
-        import_job.status = "skipped"
-        import_job.save()
-        return
-
-    track_file = track_file or models.TrackFile(track=track, source=import_job.source)
-    track_file.acoustid_track_id = acoustid_track_id
-    if from_file:
-        track_file.audio_file = ContentFile(import_job.audio_file.read())
-        track_file.audio_file.name = import_job.audio_file.name
-        track_file.duration = duration
-    elif import_job.library_track:
-        track_file.library_track = import_job.library_track
-        track_file.mimetype = import_job.library_track.audio_mimetype
-        if import_job.library_track.library.download_files:
-            raise NotImplementedError()
-        else:
-            # no downloading, we hotlink
-            pass
-    elif not import_job.audio_file and not import_job.source.startswith("file://"):
-        # not an inplace import, and we have a source, so let's download it
-        logger.info("[Import Job %s] downloading audio file from remote", import_job.pk)
-        track_file.download_file()
-    elif not import_job.audio_file and import_job.source.startswith("file://"):
-        # in place import, we set mimetype from extension
-        path, ext = os.path.splitext(import_job.source)
-        track_file.mimetype = music_utils.get_type_from_ext(ext)
-    track_file.set_audio_data()
-    track_file.save()
-    # if no cover is set on track album, we try to update it as well:
-    if not track.album.cover:
-        logger.info("[Import Job %s] retrieving album cover", import_job.pk)
-        update_album_cover(track.album, track_file)
-    import_job.status = "finished"
-    import_job.track_file = track_file
-    if import_job.audio_file:
-        # it's imported on the track, we don't need it anymore
-        import_job.audio_file.delete()
-    import_job.save()
-    logger.info("[Import Job %s] job finished", import_job.pk)
-    return track_file
-
-
 def update_album_cover(album, track_file, replace=False):
     if album.cover and not replace:
         return
@@ -240,37 +139,6 @@ def get_cover_from_fs(dir_path):
                 return {"mimetype": m, "content": c.read()}
 
 
-@celery.app.task(name="ImportJob.run", bind=True)
-@celery.require_instance(
-    models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job"
-)
-def import_job_run(self, import_job, use_acoustid=False):
-    def mark_errored(exc):
-        logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc))
-        import_job.status = "errored"
-        import_job.save(update_fields=["status"])
-
-    try:
-        tf = _do_import(import_job, use_acoustid=use_acoustid)
-        return tf.pk if tf else None
-    except Exception as exc:
-        if not settings.DEBUG:
-            try:
-                self.retry(exc=exc, countdown=30, max_retries=3)
-            except Exception:
-                mark_errored(exc)
-                raise
-        mark_errored(exc)
-        raise
-
-
-@celery.app.task(name="ImportBatch.run")
-@celery.require_instance(models.ImportBatch, "import_batch")
-def import_batch_run(import_batch):
-    for job_id in import_batch.jobs.order_by("id").values_list("id", flat=True):
-        import_job_run.delay(import_job_id=job_id)
-
-
 @celery.app.task(name="Lyrics.fetch_content")
 @celery.require_instance(models.Lyrics, "lyrics")
 def fetch_content(lyrics):
@@ -301,7 +169,7 @@ def import_batch_notify_followers(import_batch):
     collection = federation_serializers.CollectionSerializer(
         {
             "actor": library_actor,
-            "id": import_batch.get_federation_url(),
+            "id": import_batch.get_federation_id(),
             "items": track_files,
             "item_serializer": federation_serializers.AudioSerializer,
         }
@@ -312,9 +180,266 @@ def import_batch_notify_followers(import_batch):
                 "type": "Create",
                 "id": collection["id"],
                 "object": collection,
-                "actor": library_actor.url,
+                "actor": library_actor.fid,
                 "to": [f.url],
             }
         ).data
 
         activity.deliver(create, on_behalf_of=library_actor, to=[f.url])
+
+
+@celery.app.task(
+    name="music.start_library_scan",
+    retry_backoff=60,
+    max_retries=5,
+    autoretry_for=[RequestException],
+)
+@celery.require_instance(
+    models.LibraryScan.objects.select_related().filter(status="pending"), "library_scan"
+)
+def start_library_scan(library_scan):
+    data = lb.get_library_data(library_scan.library.fid, actor=library_scan.actor)
+    library_scan.modification_date = timezone.now()
+    library_scan.status = "scanning"
+    library_scan.total_files = data["totalItems"]
+    library_scan.save(update_fields=["status", "modification_date", "total_files"])
+    scan_library_page.delay(library_scan_id=library_scan.pk, page_url=data["first"])
+
+
+@celery.app.task(
+    name="music.scan_library_page",
+    retry_backoff=60,
+    max_retries=5,
+    autoretry_for=[RequestException],
+)
+@celery.require_instance(
+    models.LibraryScan.objects.select_related().filter(status="scanning"),
+    "library_scan",
+)
+def scan_library_page(library_scan, page_url):
+    data = lb.get_library_page(library_scan.library, page_url, library_scan.actor)
+    tfs = []
+
+    for item_serializer in data["items"]:
+        tf = item_serializer.save(library=library_scan.library)
+        if tf.import_status == "pending" and not tf.track:
+            # this track is not matched to any musicbrainz or other musical
+            # metadata
+            import_track_file.delay(track_file_id=tf.pk)
+        tfs.append(tf)
+
+    library_scan.processed_files = F("processed_files") + len(tfs)
+    library_scan.modification_date = timezone.now()
+    update_fields = ["modification_date", "processed_files"]
+
+    next_page = data.get("next")
+    fetch_next = next_page and next_page != page_url
+
+    if not fetch_next:
+        update_fields.append("status")
+        library_scan.status = "finished"
+    library_scan.save(update_fields=update_fields)
+
+    if fetch_next:
+        scan_library_page.delay(library_scan_id=library_scan.pk, page_url=next_page)
+
+
+def getter(data, *keys):
+    if not data:
+        return
+    v = data
+    for k in keys:
+        v = v.get(k)
+
+    return v
+
+
+class TrackFileImportError(ValueError):
+    def __init__(self, code):
+        self.code = code
+        super().__init__(code)
+
+
+def fail_import(track_file, error_code):
+    old_status = track_file.import_status
+    track_file.import_status = "errored"
+    track_file.import_details = {"error_code": error_code}
+    track_file.import_date = timezone.now()
+    track_file.save(update_fields=["import_details", "import_status", "import_date"])
+    signals.track_file_import_status_updated.send(
+        old_status=old_status,
+        new_status=track_file.import_status,
+        track_file=track_file,
+        sender=None,
+    )
+
+
+@celery.app.task(name="music.import_track_file")
+@celery.require_instance(
+    models.TrackFile.objects.filter(import_status="pending").select_related(
+        "library__actor__user"
+    ),
+    "track_file",
+)
+def import_track_file(track_file):
+    data = track_file.import_metadata or {}
+    old_status = track_file.import_status
+    try:
+        track = get_track_from_import_metadata(track_file.import_metadata or {})
+        if not track and track_file.audio_file:
+            # easy ways did not work. Now we have to be smart and use
+            # metadata from the file itself if any
+            track = import_track_data_from_file(track_file.audio_file.file, hints=data)
+        if not track and track_file.metadata:
+            # we can try to import using federation metadata
+            track = import_track_from_remote(track_file.metadata)
+    except TrackFileImportError as e:
+        return fail_import(track_file, e.code)
+    except Exception:
+        fail_import(track_file, "unknown_error")
+        raise
+    # under some situations, we want to skip the import (
+    # for instance if the user already owns the files)
+    owned_duplicates = get_owned_duplicates(track_file, track)
+    track_file.track = track
+
+    if owned_duplicates:
+        track_file.import_status = "skipped"
+        track_file.import_details = {
+            "code": "already_imported_in_owned_libraries",
+            "duplicates": list(owned_duplicates),
+        }
+        track_file.import_date = timezone.now()
+        track_file.save(
+            update_fields=["import_details", "import_status", "import_date", "track"]
+        )
+        signals.track_file_import_status_updated.send(
+            old_status=old_status,
+            new_status=track_file.import_status,
+            track_file=track_file,
+            sender=None,
+        )
+        return
+
+    # all is good, let's finalize the import
+    audio_data = track_file.get_audio_data()
+    if audio_data:
+        track_file.duration = audio_data["duration"]
+        track_file.size = audio_data["size"]
+        track_file.bitrate = audio_data["bitrate"]
+    track_file.import_status = "finished"
+    track_file.import_date = timezone.now()
+    track_file.save(
+        update_fields=[
+            "track",
+            "import_status",
+            "import_date",
+            "size",
+            "duration",
+            "bitrate",
+        ]
+    )
+    signals.track_file_import_status_updated.send(
+        old_status=old_status,
+        new_status=track_file.import_status,
+        track_file=track_file,
+        sender=None,
+    )
+
+    if not track.album.cover:
+        update_album_cover(track.album, track_file)
+
+
+def get_track_from_import_metadata(data):
+    track_mbid = getter(data, "track", "mbid")
+    track_uuid = getter(data, "track", "uuid")
+
+    if track_mbid:
+        # easiest case: there is a MBID provided in the import_metadata
+        return models.Track.get_or_create_from_api(mbid=track_mbid)[0]
+    if track_uuid:
+        # another easy case, we have a reference to a uuid of a track that
+        # already exists in our database
+        try:
+            return models.Track.objects.get(uuid=track_uuid)
+        except models.Track.DoesNotExist:
+            raise TrackFileImportError(code="track_uuid_not_found")
+
+
+def get_owned_duplicates(track_file, track):
+    """
+    Ensure we skip duplicate tracks to avoid wasting user/instance storage
+    """
+    owned_libraries = track_file.library.actor.libraries.all()
+    return (
+        models.TrackFile.objects.filter(
+            track__isnull=False, library__in=owned_libraries, track=track
+        )
+        .exclude(pk=track_file.pk)
+        .values_list("uuid", flat=True)
+    )
+
+
+@transaction.atomic
+def import_track_data_from_file(file, hints={}):
+    data = metadata.Metadata(file)
+    album = None
+    track_mbid = data.get("musicbrainz_recordingid", None)
+    album_mbid = data.get("musicbrainz_albumid", None)
+
+    if album_mbid and track_mbid:
+        # to gain performance and avoid additional mb lookups,
+        # we import from the release data, which is already cached
+        return models.Track.get_or_create_from_release(album_mbid, track_mbid)[0]
+    elif track_mbid:
+        return models.Track.get_or_create_from_api(track_mbid)[0]
+    elif album_mbid:
+        album = models.Album.get_or_create_from_api(album_mbid)[0]
+
+    artist = album.artist if album else None
+    artist_mbid = data.get("musicbrainz_artistid", None)
+    if not artist:
+        if artist_mbid:
+            artist = models.Artist.get_or_create_from_api(artist_mbid)[0]
+        else:
+            artist = models.Artist.objects.get_or_create(
+                name__iexact=data.get("artist"), defaults={"name": data.get("artist")}
+            )[0]
+
+    release_date = data.get("date", default=None)
+    if not album:
+        album = models.Album.objects.get_or_create(
+            title__iexact=data.get("album"),
+            artist=artist,
+            defaults={"title": data.get("album"), "release_date": release_date},
+        )[0]
+    position = data.get("track_number", default=None)
+    track = models.Track.objects.get_or_create(
+        title__iexact=data.get("title"),
+        album=album,
+        defaults={"title": data.get("title"), "position": position},
+    )[0]
+    return track
+
+
+@receiver(signals.track_file_import_status_updated)
+def broadcast_import_status_update_to_owner(
+    old_status, new_status, track_file, **kwargs
+):
+    user = track_file.library.actor.get_user()
+    if not user:
+        return
+    group = "user.{}.imports".format(user.pk)
+    channels.group_send(
+        group,
+        {
+            "type": "event.send",
+            "text": "",
+            "data": {
+                "type": "import.status_updated",
+                "track_file": serializers.TrackFileForOwnerSerializer(track_file).data,
+                "old_status": old_status,
+                "new_status": new_status,
+            },
+        },
+    )
diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py
index 92187d69a12f004086acc4da7d5cada9cc43c630..6da9ad9493647ad398a35e27faa8f7d04fb0eb7f 100644
--- a/api/funkwhale_api/music/utils.py
+++ b/api/funkwhale_api/music/utils.py
@@ -58,3 +58,13 @@ def get_audio_file_data(f):
     d["length"] = data.info.length
 
     return d
+
+
+def get_actor_from_request(request):
+    actor = None
+    if hasattr(request, "actor"):
+        actor = request.actor
+    elif request.user.is_authenticated:
+        actor = request.user.actor
+
+    return actor
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 77a82dd21d36b2502eec37d2f188e58d1ab31501..e8ddd00a74040716ad18789922a94ab72dc8795c 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -1,32 +1,25 @@
-import json
 import logging
 import urllib
 
 from django.conf import settings
-from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
-from django.db.models import Count
+from django.db.models import Count, Prefetch, Sum
 from django.db.models.functions import Length
 from django.utils import timezone
-from musicbrainzngs import ResponseError
+
 from rest_framework import mixins
+from rest_framework import permissions
 from rest_framework import settings as rest_settings
 from rest_framework import views, viewsets
 from rest_framework.decorators import detail_route, list_route
 from rest_framework.response import Response
 from taggit.models import Tag
 
-from funkwhale_api.common import utils as funkwhale_utils
-from funkwhale_api.common.permissions import ConditionalAuthentication
+from funkwhale_api.common import utils as common_utils
+from funkwhale_api.common import permissions as common_permissions
 from funkwhale_api.federation.authentication import SignatureAuthentication
-from funkwhale_api.federation.models import LibraryTrack
-from funkwhale_api.musicbrainz import api
-from funkwhale_api.requests.models import ImportRequest
-from funkwhale_api.users.permissions import HasUserPermission
 
-from . import filters, importers, models
-from . import permissions as music_permissions
-from . import serializers, tasks, utils
+from . import filters, models, serializers, tasks, utils
 
 logger = logging.getLogger(__name__)
 
@@ -41,107 +34,65 @@ class TagViewSetMixin(object):
 
 
 class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
-    queryset = models.Artist.objects.with_albums()
+    queryset = models.Artist.objects.all()
     serializer_class = serializers.ArtistWithAlbumsSerializer
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [common_permissions.ConditionalAuthentication]
     filter_class = filters.ArtistFilter
     ordering_fields = ("id", "name", "creation_date")
 
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        albums = models.Album.objects.with_tracks_count()
+        return queryset.prefetch_related(Prefetch("albums", queryset=albums)).distinct()
+
 
 class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = (
-        models.Album.objects.all()
-        .order_by("artist", "release_date")
-        .select_related()
-        .prefetch_related("tracks__artist", "tracks__files")
+        models.Album.objects.all().order_by("artist", "release_date").select_related()
     )
     serializer_class = serializers.AlbumSerializer
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [common_permissions.ConditionalAuthentication]
     ordering_fields = ("creation_date", "release_date", "title")
     filter_class = filters.AlbumFilter
 
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        tracks = models.Track.objects.annotate_playable_by_actor(
+            utils.get_actor_from_request(self.request)
+        ).select_related("artist")
+        qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks))
+        return qs.distinct()
+
 
-class ImportBatchViewSet(
+class LibraryViewSet(
     mixins.CreateModelMixin,
     mixins.ListModelMixin,
     mixins.RetrieveModelMixin,
+    mixins.UpdateModelMixin,
+    mixins.DestroyModelMixin,
     viewsets.GenericViewSet,
 ):
+    lookup_field = "uuid"
     queryset = (
-        models.ImportBatch.objects.select_related()
+        models.Library.objects.all()
         .order_by("-creation_date")
-        .annotate(job_count=Count("jobs"))
+        .annotate(_files_count=Count("files"))
+        .annotate(_size=Sum("files__size"))
     )
-    serializer_class = serializers.ImportBatchSerializer
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["library", "upload"]
-    permission_operator = "or"
-    filter_class = filters.ImportBatchFilter
-
-    def perform_create(self, serializer):
-        serializer.save(submitted_by=self.request.user)
-
-    def get_queryset(self):
-        qs = super().get_queryset()
-        # if user do not have library permission, we limit to their
-        # own jobs
-        if not self.request.user.has_permissions("library"):
-            qs = qs.filter(submitted_by=self.request.user)
-        return qs
-
-
-class ImportJobViewSet(
-    mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet
-):
-    queryset = models.ImportJob.objects.all().select_related()
-    serializer_class = serializers.ImportJobSerializer
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["library", "upload"]
-    permission_operator = "or"
-    filter_class = filters.ImportJobFilter
+    serializer_class = serializers.LibraryForOwnerSerializer
+    permission_classes = [
+        permissions.IsAuthenticated,
+        common_permissions.OwnerPermission,
+    ]
+    owner_field = "actor.user"
+    owner_checks = ["read", "write"]
 
     def get_queryset(self):
         qs = super().get_queryset()
-        # if user do not have library permission, we limit to their
-        # own jobs
-        if not self.request.user.has_permissions("library"):
-            qs = qs.filter(batch__submitted_by=self.request.user)
-        return qs
-
-    @list_route(methods=["get"])
-    def stats(self, request, *args, **kwargs):
-        if not request.user.has_permissions("library"):
-            return Response(status=403)
-        qs = models.ImportJob.objects.all()
-        filterset = filters.ImportJobFilter(request.GET, queryset=qs)
-        qs = filterset.qs
-        qs = qs.values("status").order_by("status")
-        qs = qs.annotate(status_count=Count("status"))
-
-        data = {}
-        for row in qs:
-            data[row["status"]] = row["status_count"]
-
-        for s, _ in models.IMPORT_STATUS_CHOICES:
-            data.setdefault(s, 0)
-
-        data["count"] = sum([v for v in data.values()])
-        return Response(data)
-
-    @list_route(methods=["post"])
-    def run(self, request, *args, **kwargs):
-        serializer = serializers.ImportJobRunSerializer(data=request.data)
-        serializer.is_valid(raise_exception=True)
-        payload = serializer.save()
-
-        return Response(payload)
+        return qs.filter(actor=self.request.user.actor)
 
     def perform_create(self, serializer):
-        source = "file://" + serializer.validated_data["audio_file"].name
-        serializer.save(source=source)
-        funkwhale_utils.on_commit(
-            tasks.import_job_run.delay, import_job_id=serializer.instance.pk
-        )
+        serializer.save(actor=self.request.user.actor)
 
 
 class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
@@ -151,14 +102,13 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
 
     queryset = models.Track.objects.all().for_nested_serialization()
     serializer_class = serializers.TrackSerializer
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [common_permissions.ConditionalAuthentication]
     filter_class = filters.TrackFilter
     ordering_fields = (
         "creation_date",
         "title",
-        "album__title",
         "album__release_date",
-        "position",
+        "size",
         "artist__name",
     )
 
@@ -169,7 +119,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
         if user.is_authenticated and filter_favorites == "true":
             queryset = queryset.filter(track_favorites__user=user)
 
-        return queryset
+        queryset = queryset.annotate_playable_by_actor(
+            utils.get_actor_from_request(self.request)
+        )
+        return queryset.distinct()
 
     @detail_route(methods=["get"])
     @transaction.non_atomic_requests
@@ -228,40 +181,37 @@ def get_file_path(audio_file):
         return path.encode("utf-8")
 
 
-def handle_serve(track_file):
+def handle_serve(track_file, user):
     f = track_file
     # we update the accessed_date
     f.accessed_date = timezone.now()
     f.save(update_fields=["accessed_date"])
 
-    mt = f.mimetype
-    audio_file = f.audio_file
-    try:
-        library_track = f.library_track
-    except ObjectDoesNotExist:
-        library_track = None
-    if library_track and not audio_file:
-        if not library_track.audio_file:
-            # we need to populate from cache
-            with transaction.atomic():
-                # why the transaction/select_for_update?
-                # this is because browsers may send multiple requests
-                # in a short time range, for partial content,
-                # thus resulting in multiple downloads from the remote
-                qs = LibraryTrack.objects.select_for_update()
-                library_track = qs.get(pk=library_track.pk)
-                library_track.download_audio()
-            track_file.library_track = library_track
-            track_file.set_audio_data()
-            track_file.save(update_fields=["bitrate", "duration", "size"])
-
-        audio_file = library_track.audio_file
-        file_path = get_file_path(audio_file)
-        mt = library_track.audio_mimetype
-    elif audio_file:
-        file_path = get_file_path(audio_file)
+    if f.audio_file:
+        file_path = get_file_path(f.audio_file)
+
+    elif f.source and (
+        f.source.startswith("http://") or f.source.startswith("https://")
+    ):
+        # we need to populate from cache
+        with transaction.atomic():
+            # why the transaction/select_for_update?
+            # this is because browsers may send multiple requests
+            # in a short time range, for partial content,
+            # thus resulting in multiple downloads from the remote
+            qs = f.__class__.objects.select_for_update()
+            f = qs.get(pk=f.pk)
+            f.download_audio_from_remote(user=user)
+        data = f.get_audio_data()
+        if data:
+            f.duration = data["duration"]
+            f.size = data["size"]
+            f.bitrate = data["bitrate"]
+            f.save(update_fields=["bitrate", "duration", "size"])
+        file_path = get_file_path(f.audio_file)
     elif f.source and f.source.startswith("file://"):
         file_path = get_file_path(f.source.replace("file://", "", 1))
+    mt = f.mimetype
     if mt:
         response = Response(content_type=mt)
     else:
@@ -278,39 +228,93 @@ def handle_serve(track_file):
     return response
 
 
-class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
-    queryset = (
-        models.TrackFile.objects.all()
-        .select_related("track__artist", "track__album")
-        .order_by("-id")
-    )
-    serializer_class = serializers.TrackFileSerializer
+class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
+    queryset = models.Track.objects.all()
+    serializer_class = serializers.TrackSerializer
     authentication_classes = (
         rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
         + [SignatureAuthentication]
     )
-    permission_classes = [music_permissions.Listen]
+    permission_classes = [common_permissions.ConditionalAuthentication]
+    lookup_field = "uuid"
+
+    def retrieve(self, request, *args, **kwargs):
+        track = self.get_object()
+        actor = utils.get_actor_from_request(request)
+        queryset = track.files.select_related("track__album__artist", "track__artist")
+        explicit_file = request.GET.get("file")
+        if explicit_file:
+            queryset = queryset.filter(uuid=explicit_file)
+        queryset = queryset.playable_by(actor)
+        tf = queryset.first()
+        if not tf:
+            return Response(status=404)
 
-    @detail_route(methods=["get"])
-    def serve(self, request, *args, **kwargs):
-        queryset = models.TrackFile.objects.select_related(
-            "library_track", "track__album__artist", "track__artist"
+        return handle_serve(tf, user=request.user)
+
+
+class TrackFileViewSet(
+    mixins.ListModelMixin,
+    mixins.CreateModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+    viewsets.GenericViewSet,
+):
+    lookup_field = "uuid"
+    queryset = (
+        models.TrackFile.objects.all()
+        .order_by("-creation_date")
+        .select_related("library", "track__artist", "track__album__artist")
+    )
+    serializer_class = serializers.TrackFileForOwnerSerializer
+    permission_classes = [
+        permissions.IsAuthenticated,
+        common_permissions.OwnerPermission,
+    ]
+    owner_field = "library.actor.user"
+    owner_checks = ["read", "write"]
+    filter_class = filters.TrackFileFilter
+    ordering_fields = (
+        "creation_date",
+        "import_date",
+        "bitrate",
+        "size",
+        "artist__name",
+    )
+
+    def get_queryset(self):
+        qs = super().get_queryset()
+        return qs.filter(library__actor=self.request.user.actor)
+
+    @list_route(methods=["post"])
+    def action(self, request, *args, **kwargs):
+        queryset = self.get_queryset()
+        serializer = serializers.TrackFileActionSerializer(
+            request.data, queryset=queryset
         )
-        try:
-            return handle_serve(queryset.get(pk=kwargs["pk"]))
-        except models.TrackFile.DoesNotExist:
-            return Response(status=404)
+        serializer.is_valid(raise_exception=True)
+        result = serializer.save()
+        return Response(result, status=200)
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        context["user"] = self.request.user
+        return context
+
+    def perform_create(self, serializer):
+        tf = serializer.save()
+        common_utils.on_commit(tasks.import_track_file.delay, track_file_id=tf.pk)
 
 
 class TagViewSet(viewsets.ReadOnlyModelViewSet):
     queryset = Tag.objects.all().order_by("name")
     serializer_class = serializers.TagSerializer
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [common_permissions.ConditionalAuthentication]
 
 
 class Search(views.APIView):
     max_results = 3
-    permission_classes = [ConditionalAuthentication]
+    permission_classes = [common_permissions.ConditionalAuthentication]
 
     def get(self, request, *args, **kwargs):
         query = request.GET["query"]
@@ -340,7 +344,6 @@ class Search(views.APIView):
             models.Track.objects.all()
             .filter(query_obj)
             .select_related("artist", "album__artist")
-            .prefetch_related("files")
         )[: self.max_results]
 
     def get_albums(self, query):
@@ -350,7 +353,7 @@ class Search(views.APIView):
             models.Album.objects.all()
             .filter(query_obj)
             .select_related()
-            .prefetch_related("tracks__files")
+            .prefetch_related("tracks")
         )[: self.max_results]
 
     def get_artists(self, query):
@@ -372,99 +375,3 @@ class Search(views.APIView):
         )
 
         return qs.filter(query_obj)[: self.max_results]
-
-
-class SubmitViewSet(viewsets.ViewSet):
-    queryset = models.ImportBatch.objects.none()
-    permission_classes = (HasUserPermission,)
-    required_permissions = ["library"]
-
-    @list_route(methods=["post"])
-    @transaction.non_atomic_requests
-    def single(self, request, *args, **kwargs):
-        try:
-            models.Track.objects.get(mbid=request.POST["mbid"])
-            return Response({})
-        except models.Track.DoesNotExist:
-            pass
-        batch = models.ImportBatch.objects.create(submitted_by=request.user)
-        job = models.ImportJob.objects.create(
-            mbid=request.POST["mbid"], batch=batch, source=request.POST["import_url"]
-        )
-        tasks.import_job_run.delay(import_job_id=job.pk)
-        serializer = serializers.ImportBatchSerializer(batch)
-        return Response(serializer.data, status=201)
-
-    def get_import_request(self, data):
-        try:
-            raw = data["importRequest"]
-        except KeyError:
-            return
-
-        pk = int(raw)
-        try:
-            return ImportRequest.objects.get(pk=pk)
-        except ImportRequest.DoesNotExist:
-            pass
-
-    @list_route(methods=["post"])
-    @transaction.non_atomic_requests
-    def album(self, request, *args, **kwargs):
-        data = json.loads(request.body.decode("utf-8"))
-        import_request = self.get_import_request(data)
-        import_data, batch = self._import_album(
-            data, request, batch=None, import_request=import_request
-        )
-        return Response(import_data)
-
-    @transaction.atomic
-    def _import_album(self, data, request, batch=None, import_request=None):
-        # we import the whole album here to prevent race conditions that occurs
-        # when using get_or_create_from_api in tasks
-        album_data = api.releases.get(
-            id=data["releaseId"], includes=models.Album.api_includes
-        )["release"]
-        cleaned_data = models.Album.clean_musicbrainz_data(album_data)
-        album = importers.load(
-            models.Album, cleaned_data, album_data, import_hooks=[models.import_tracks]
-        )
-        try:
-            album.get_image()
-        except ResponseError:
-            pass
-        if not batch:
-            batch = models.ImportBatch.objects.create(
-                submitted_by=request.user, import_request=import_request
-            )
-        for row in data["tracks"]:
-            try:
-                models.TrackFile.objects.get(track__mbid=row["mbid"])
-            except models.TrackFile.DoesNotExist:
-                job = models.ImportJob.objects.create(
-                    mbid=row["mbid"], batch=batch, source=row["source"]
-                )
-                funkwhale_utils.on_commit(
-                    tasks.import_job_run.delay, import_job_id=job.pk
-                )
-
-        serializer = serializers.ImportBatchSerializer(batch)
-        return serializer.data, batch
-
-    @list_route(methods=["post"])
-    @transaction.non_atomic_requests
-    def artist(self, request, *args, **kwargs):
-        data = json.loads(request.body.decode("utf-8"))
-        import_request = self.get_import_request(data)
-        artist_data = api.artists.get(id=data["artistId"])["artist"]
-        cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
-        importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
-
-        import_data = []
-        batch = None
-        for row in data["albums"]:
-            row_data, batch = self._import_album(
-                row, request, batch=batch, import_request=import_request
-            )
-            import_data.append(row_data)
-
-        return Response(import_data[0])
diff --git a/api/funkwhale_api/playlists/filters.py b/api/funkwhale_api/playlists/filters.py
index 144b0f049b23e78002fc3f79a87d395c87b141d1..1f12521f050cd5bd4d1236bda53df8dbb52ab330 100644
--- a/api/funkwhale_api/playlists/filters.py
+++ b/api/funkwhale_api/playlists/filters.py
@@ -8,7 +8,7 @@ from . import models
 
 class PlaylistFilter(filters.FilterSet):
     q = filters.CharFilter(name="_", method="filter_q")
-    listenable = filters.BooleanFilter(name="_", method="filter_listenable")
+    playable = filters.BooleanFilter(name="_", method="filter_playable")
 
     class Meta:
         model = models.Playlist
@@ -16,10 +16,10 @@ class PlaylistFilter(filters.FilterSet):
             "user": ["exact"],
             "name": ["exact", "icontains"],
             "q": "exact",
-            "listenable": "exact",
+            "playable": "exact",
         }
 
-    def filter_listenable(self, queryset, name, value):
+    def filter_playable(self, queryset, name, value):
         queryset = queryset.annotate(plts_count=Count("playlist_tracks"))
         if value:
             return queryset.filter(plts_count__gt=0)
diff --git a/api/funkwhale_api/providers/audiofile/tasks.py b/api/funkwhale_api/providers/audiofile/tasks.py
index ee486345a7cc3535e64edec9a994df7e7b8f935c..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
--- a/api/funkwhale_api/providers/audiofile/tasks.py
+++ b/api/funkwhale_api/providers/audiofile/tasks.py
@@ -1,45 +0,0 @@
-from django.db import transaction
-
-from funkwhale_api.music import metadata, models
-
-
-@transaction.atomic
-def import_track_data_from_path(path):
-    data = metadata.Metadata(path)
-    album = None
-    track_mbid = data.get("musicbrainz_recordingid", None)
-    album_mbid = data.get("musicbrainz_albumid", None)
-
-    if album_mbid and track_mbid:
-        # to gain performance and avoid additional mb lookups,
-        # we import from the release data, which is already cached
-        return models.Track.get_or_create_from_release(album_mbid, track_mbid)[0]
-    elif track_mbid:
-        return models.Track.get_or_create_from_api(track_mbid)[0]
-    elif album_mbid:
-        album = models.Album.get_or_create_from_api(album_mbid)[0]
-
-    artist = album.artist if album else None
-    artist_mbid = data.get("musicbrainz_artistid", None)
-    if not artist:
-        if artist_mbid:
-            artist = models.Artist.get_or_create_from_api(artist_mbid)[0]
-        else:
-            artist = models.Artist.objects.get_or_create(
-                name__iexact=data.get("artist"), defaults={"name": data.get("artist")}
-            )[0]
-
-    release_date = data.get("date", default=None)
-    if not album:
-        album = models.Album.objects.get_or_create(
-            title__iexact=data.get("album"),
-            artist=artist,
-            defaults={"title": data.get("album"), "release_date": release_date},
-        )[0]
-    position = data.get("track_number", default=None)
-    track = models.Track.objects.get_or_create(
-        title__iexact=data.get("title"),
-        album=album,
-        defaults={"title": data.get("title"), "position": position},
-    )[0]
-    return track
diff --git a/api/funkwhale_api/providers/urls.py b/api/funkwhale_api/providers/urls.py
index 55a1193f503c33784a1876fd6700ab0f28d79041..dc8afeee7ff7fda6afbc41f54964b627747c50b9 100644
--- a/api/funkwhale_api/providers/urls.py
+++ b/api/funkwhale_api/providers/urls.py
@@ -1,16 +1,10 @@
 from django.conf.urls import include, url
 
 urlpatterns = [
-    url(
-        r"^youtube/",
-        include(
-            ("funkwhale_api.providers.youtube.urls", "youtube"), namespace="youtube"
-        ),
-    ),
     url(
         r"^musicbrainz/",
         include(
             ("funkwhale_api.musicbrainz.urls", "musicbrainz"), namespace="musicbrainz"
         ),
-    ),
+    )
 ]
diff --git a/api/funkwhale_api/providers/youtube/__init__.py b/api/funkwhale_api/providers/youtube/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/api/funkwhale_api/providers/youtube/client.py b/api/funkwhale_api/providers/youtube/client.py
deleted file mode 100644
index 2235fcdc83a994ea0da1024962e9d6bd45704184..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/youtube/client.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import threading
-
-from apiclient.discovery import build
-from dynamic_preferences.registries import global_preferences_registry as registry
-
-YOUTUBE_API_SERVICE_NAME = "youtube"
-YOUTUBE_API_VERSION = "v3"
-VIDEO_BASE_URL = "https://www.youtube.com/watch?v={0}"
-
-
-def _do_search(query):
-    manager = registry.manager()
-    youtube = build(
-        YOUTUBE_API_SERVICE_NAME,
-        YOUTUBE_API_VERSION,
-        developerKey=manager["providers_youtube__api_key"],
-    )
-
-    return youtube.search().list(q=query, part="id,snippet", maxResults=25).execute()
-
-
-class Client(object):
-    def search(self, query):
-        search_response = _do_search(query)
-        videos = []
-        for search_result in search_response.get("items", []):
-            if search_result["id"]["kind"] == "youtube#video":
-                search_result["full_url"] = VIDEO_BASE_URL.format(
-                    search_result["id"]["videoId"]
-                )
-                videos.append(search_result)
-        return videos
-
-    def search_multiple(self, queries):
-        results = {}
-
-        def search(key, query):
-            results[key] = self.search(query)
-
-        threads = [
-            threading.Thread(target=search, args=(key, query))
-            for key, query in queries.items()
-        ]
-        for thread in threads:
-            thread.start()
-        for thread in threads:
-            thread.join()
-
-        return results
-
-    def to_funkwhale(self, result):
-        """
-        We convert youtube results to something more generic.
-
-        {
-            "id": "video id",
-            "type": "youtube#video",
-            "url": "https://www.youtube.com/watch?v=id",
-            "description": "description",
-            "channelId": "Channel id",
-            "title": "Title",
-            "channelTitle": "channel Title",
-            "publishedAt": "2012-08-22T18:41:03.000Z",
-            "cover": "http://coverurl"
-        }
-        """
-        return {
-            "id": result["id"]["videoId"],
-            "url": "https://www.youtube.com/watch?v={}".format(result["id"]["videoId"]),
-            "type": result["id"]["kind"],
-            "title": result["snippet"]["title"],
-            "description": result["snippet"]["description"],
-            "channelId": result["snippet"]["channelId"],
-            "channelTitle": result["snippet"]["channelTitle"],
-            "publishedAt": result["snippet"]["publishedAt"],
-            "cover": result["snippet"]["thumbnails"]["high"]["url"],
-        }
-
-
-client = Client()
diff --git a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py b/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py
deleted file mode 100644
index 2d950eb6b202f1f728b4f345733488e689261695..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/youtube/dynamic_preferences_registry.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from django import forms
-from dynamic_preferences.registries import global_preferences_registry
-from dynamic_preferences.types import Section, StringPreference
-
-youtube = Section("providers_youtube")
-
-
-@global_preferences_registry.register
-class APIKey(StringPreference):
-    section = youtube
-    name = "api_key"
-    default = "CHANGEME"
-    verbose_name = "YouTube API key"
-    help_text = "The API key used to query YouTube. Get one at https://console.developers.google.com/."
-    widget = forms.PasswordInput
-    field_kwargs = {"required": False}
diff --git a/api/funkwhale_api/providers/youtube/urls.py b/api/funkwhale_api/providers/youtube/urls.py
deleted file mode 100644
index d9687ac9f8f0e1af94eef00868ce7c64dbe3d406..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/youtube/urls.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.conf.urls import url
-
-from .views import APISearch, APISearchs
-
-urlpatterns = [
-    url(r"^search/$", APISearch.as_view(), name="search"),
-    url(r"^searchs/$", APISearchs.as_view(), name="searchs"),
-]
diff --git a/api/funkwhale_api/providers/youtube/views.py b/api/funkwhale_api/providers/youtube/views.py
deleted file mode 100644
index 5e1982f48e80ca5722c5ebbb712151cd12a02eb2..0000000000000000000000000000000000000000
--- a/api/funkwhale_api/providers/youtube/views.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from rest_framework.response import Response
-from rest_framework.views import APIView
-
-from funkwhale_api.common.permissions import ConditionalAuthentication
-
-from .client import client
-
-
-class APISearch(APIView):
-    permission_classes = [ConditionalAuthentication]
-
-    def get(self, request, *args, **kwargs):
-        results = client.search(request.GET["query"])
-        return Response([client.to_funkwhale(result) for result in results])
-
-
-class APISearchs(APIView):
-    permission_classes = [ConditionalAuthentication]
-
-    def post(self, request, *args, **kwargs):
-        results = client.search_multiple(request.data)
-        return Response(
-            {
-                key: [client.to_funkwhale(result) for result in group]
-                for key, group in results.items()
-            }
-        )
diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py
index b2469a8cade7aad9c27d3aa6e5aed649704477ee..b67bfb83aa355a7831c9be595e517e0107b7d64b 100644
--- a/api/funkwhale_api/subsonic/views.py
+++ b/api/funkwhale_api/subsonic/views.py
@@ -177,13 +177,11 @@ class SubsonicViewSet(viewsets.GenericViewSet):
     @find_object(music_models.Track.objects.all())
     def stream(self, request, *args, **kwargs):
         track = kwargs.pop("obj")
-        queryset = track.files.select_related(
-            "library_track", "track__album__artist", "track__artist"
-        )
+        queryset = track.files.select_related("track__album__artist", "track__artist")
         track_file = queryset.first()
         if not track_file:
             return response.Response(status=404)
-        return music_views.handle_serve(track_file)
+        return music_views.handle_serve(track_file=track_file, user=request.user)
 
     @list_route(methods=["get", "post"], url_name="star", url_path="star")
     @find_object(music_models.Track.objects.all())
diff --git a/api/funkwhale_api/taskapp/celery.py b/api/funkwhale_api/taskapp/celery.py
index 98e980f07273c640c08871bf8de0c42abcd344a1..bf7d4da697d0fb541a65a277fcc87b3d7b11c565 100644
--- a/api/funkwhale_api/taskapp/celery.py
+++ b/api/funkwhale_api/taskapp/celery.py
@@ -2,20 +2,29 @@
 from __future__ import absolute_import
 
 import functools
+import traceback as tb
 import os
-
-from celery import Celery
+import logging
+import celery.app.task
 from django.apps import AppConfig
 from django.conf import settings
 
+
+logger = logging.getLogger("celery")
+
 if not settings.configured:
     # set the default Django settings module for the 'celery' program.
     os.environ.setdefault(
         "DJANGO_SETTINGS_MODULE", "config.settings.local"
     )  # pragma: no cover
 
+app = celery.Celery("funkwhale_api")
+
 
-app = Celery("funkwhale_api")
+@celery.signals.task_failure.connect
+def process_failure(sender, task_id, exception, args, kwargs, traceback, einfo, **kw):
+    print("[celery] Error during task {}: {}".format(task_id, einfo.exception))
+    tb.print_exc()
 
 
 class CeleryConfig(AppConfig):
diff --git a/api/funkwhale_api/users/dynamic_preferences_registry.py b/api/funkwhale_api/users/dynamic_preferences_registry.py
index 08f5730a81f7f9791fbc109eb14321f3de9dad64..4e910577fbf97b062d341b88541473d786da8405 100644
--- a/api/funkwhale_api/users/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/users/dynamic_preferences_registry.py
@@ -28,3 +28,13 @@ class DefaultPermissions(common_preferences.StringListPreference):
     help_text = "A list of default preferences to give to all registered users."
     choices = [(k, c["label"]) for k, c in models.PERMISSIONS_CONFIGURATION.items()]
     field_kwargs = {"choices": choices, "required": False}
+
+
+@global_preferences_registry.register
+class UploadQuota(types.IntPreference):
+    show_in_api = True
+    section = users
+    name = "upload_quota"
+    default = 1000
+    verbose_name = "Upload quota"
+    help_text = "Default upload quota applied to each users, in MB. This can be overrided on a per-user basis."
diff --git a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py
index b731e327951573b3092d098ab9c9b3c0dfcdf9df..b000024d0d093d5a9a063c14da1f11538edc8a83 100644
--- a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py
+++ b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py
@@ -5,19 +5,21 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('users', '0007_auto_20180524_2009'),
-    ]
+    dependencies = [("users", "0007_auto_20180524_2009")]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='last_activity',
+            model_name="user",
+            name="last_activity",
             field=models.DateTimeField(blank=True, default=None, null=True),
         ),
         migrations.AlterField(
-            model_name='user',
-            name='permission_library',
-            field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'),
+            model_name="user",
+            name="permission_library",
+            field=models.BooleanField(
+                default=False,
+                help_text="Manage library, delete files, tracks, artists, albums...",
+                verbose_name="Manage library",
+            ),
         ),
     ]
diff --git a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
index e8204c4e4748371b7906d9115b0d529f30a35db7..cb9f12c60566feff60388d0ff92e8dedab77472e 100644
--- a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
+++ b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
@@ -8,24 +8,46 @@ import django.utils.timezone
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('users', '0008_auto_20180617_1531'),
-    ]
+    dependencies = [("users", "0008_auto_20180617_1531")]
 
     operations = [
         migrations.CreateModel(
-            name='Invitation',
+            name="Invitation",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
-                ('expiration_date', models.DateTimeField()),
-                ('code', models.CharField(max_length=50, unique=True)),
-                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "creation_date",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("expiration_date", models.DateTimeField()),
+                ("code", models.CharField(max_length=50, unique=True)),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="invitations",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
         ),
         migrations.AddField(
-            model_name='user',
-            name='invitation',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'),
+            model_name="user",
+            name="invitation",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="users",
+                to="users.Invitation",
+            ),
         ),
     ]
diff --git a/api/funkwhale_api/users/migrations/0010_user_avatar.py b/api/funkwhale_api/users/migrations/0010_user_avatar.py
index da60439becbe72c694129bc9f372927727810751..e50aa40611d3f2602f864b16b599859c3840999d 100644
--- a/api/funkwhale_api/users/migrations/0010_user_avatar.py
+++ b/api/funkwhale_api/users/migrations/0010_user_avatar.py
@@ -7,14 +7,22 @@ import funkwhale_api.common.validators
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('users', '0009_auto_20180619_2024'),
-    ]
+    dependencies = [("users", "0009_auto_20180619_2024")]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='avatar',
-            field=models.ImageField(blank=True, max_length=150, null=True, upload_to=funkwhale_api.common.utils.ChunkedPath('users/avatars'), validators=[funkwhale_api.common.validators.ImageDimensionsValidator(max_height=400, max_width=400, min_height=50, min_width=50)]),
-        ),
+            model_name="user",
+            name="avatar",
+            field=models.ImageField(
+                blank=True,
+                max_length=150,
+                null=True,
+                upload_to=funkwhale_api.common.utils.ChunkedPath("users/avatars"),
+                validators=[
+                    funkwhale_api.common.validators.ImageDimensionsValidator(
+                        max_height=400, max_width=400, min_height=50, min_width=50
+                    )
+                ],
+            ),
+        )
     ]
diff --git a/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py b/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py
index 5b5a1cabc163e2a77679683a98c08e80a9ac9144..5115eed8608e7d1bc46c583f6b0584905e5c59c4 100644
--- a/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py
+++ b/api/funkwhale_api/users/migrations/0011_auto_20180721_1317.py
@@ -10,19 +10,41 @@ import versatileimagefield.fields
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('federation', '0006_auto_20180521_1702'),
-        ('users', '0010_user_avatar'),
+        ("federation", "0006_auto_20180521_1702"),
+        ("users", "0010_user_avatar"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='actor',
-            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='federation.Actor'),
+            model_name="user",
+            name="actor",
+            field=models.OneToOneField(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="user",
+                to="federation.Actor",
+            ),
         ),
         migrations.AlterField(
-            model_name='user',
-            name='avatar',
-            field=versatileimagefield.fields.VersatileImageField(blank=True, max_length=150, null=True, upload_to=funkwhale_api.common.utils.ChunkedPath('users/avatars', preserve_file_name=False), validators=[funkwhale_api.common.validators.ImageDimensionsValidator(min_height=50, min_width=50), funkwhale_api.common.validators.FileValidator(allowed_extensions=['png', 'jpg', 'jpeg', 'gif'], max_size=2097152)]),
+            model_name="user",
+            name="avatar",
+            field=versatileimagefield.fields.VersatileImageField(
+                blank=True,
+                max_length=150,
+                null=True,
+                upload_to=funkwhale_api.common.utils.ChunkedPath(
+                    "users/avatars", preserve_file_name=False
+                ),
+                validators=[
+                    funkwhale_api.common.validators.ImageDimensionsValidator(
+                        min_height=50, min_width=50
+                    ),
+                    funkwhale_api.common.validators.FileValidator(
+                        allowed_extensions=["png", "jpg", "jpeg", "gif"],
+                        max_size=2097152,
+                    ),
+                ],
+            ),
         ),
     ]
diff --git a/api/funkwhale_api/users/migrations/0012_user_upload_quota.py b/api/funkwhale_api/users/migrations/0012_user_upload_quota.py
new file mode 100644
index 0000000000000000000000000000000000000000..64e47343c59591d5e0df9289ae389e5477693355
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0012_user_upload_quota.py
@@ -0,0 +1,16 @@
+# Generated by Django 2.0.7 on 2018-08-01 16:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("users", "0011_auto_20180721_1317")]
+
+    operations = [
+        migrations.AddField(
+            model_name="user",
+            name="upload_quota",
+            field=models.PositiveIntegerField(blank=True, null=True),
+        )
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 3ad56ea6428815d7c5969dcde466b554cf752391..d2e1ac65ca4f1c3fa74c53267c280869f337ca30 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -122,6 +122,8 @@ class User(AbstractUser):
         blank=True,
     )
 
+    upload_quota = models.PositiveIntegerField(null=True, blank=True)
+
     def __str__(self):
         return self.username
 
@@ -182,6 +184,32 @@ class User(AbstractUser):
             self.last_activity = now
             self.save(update_fields=["last_activity"])
 
+    def create_actor(self):
+        self.actor = create_actor(self)
+        self.save(update_fields=["actor"])
+        return self.actor
+
+    def get_upload_quota(self):
+        return self.upload_quota or preferences.get("users__upload_quota")
+
+    def get_quota_status(self):
+        data = self.actor.get_current_usage()
+        max_ = self.get_upload_quota()
+        return {
+            "max": max_,
+            "remaining": max(max_ - (data["total"] / 1000 / 1000), 0),
+            "current": data["total"] / 1000 / 1000,
+            "skipped": data["skipped"] / 1000 / 1000,
+            "pending": data["pending"] / 1000 / 1000,
+            "finished": data["finished"] / 1000 / 1000,
+            "errored": data["errored"] / 1000 / 1000,
+        }
+
+    def get_channels_groups(self):
+        groups = ["imports"]
+
+        return ["user.{}.{}".format(self.pk, g) for g in groups]
+
 
 def generate_code(length=10):
     return "".join(
@@ -229,7 +257,7 @@ def create_actor(user):
         "type": "Person",
         "name": user.username,
         "manually_approves_followers": False,
-        "url": federation_utils.full_url(
+        "fid": federation_utils.full_url(
             reverse("federation:actors-detail", kwargs={"preferred_username": username})
         ),
         "shared_inbox_url": federation_utils.full_url(
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 2f227158448ac69a13ec4fe503219b33408a036b..bcacc3144614c9780034f9a5db1c1c044306671b 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -109,6 +109,16 @@ class UserReadSerializer(serializers.ModelSerializer):
         return o.get_permissions()
 
 
+class MeSerializer(UserReadSerializer):
+    quota_status = serializers.SerializerMethodField()
+
+    class Meta(UserReadSerializer.Meta):
+        fields = UserReadSerializer.Meta.fields + ["quota_status"]
+
+    def get_quota_status(self, o):
+        return o.get_quota_status() if o.actor else 0
+
+
 class PasswordResetSerializer(PRS):
     def get_email_options(self):
         return {"extra_email_context": {"funkwhale_url": settings.FUNKWHALE_URL}}
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index 20d63d788f349643b9065038102cabfd60a49ce0..3ca0c6b611e24013d81bc2d726f8f4aa1a510bf2 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -31,7 +31,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
     @list_route(methods=["get"])
     def me(self, request, *args, **kwargs):
         """Return information about the current user"""
-        serializer = serializers.UserReadSerializer(request.user)
+        serializer = serializers.MeSerializer(request.user)
         return Response(serializer.data)
 
     @detail_route(methods=["get", "post", "delete"], url_path="subsonic-token")
diff --git a/api/tests/common/test_scripts.py b/api/tests/common/test_scripts.py
index 40d9ea0a7aae1f18611a74e956bcd2ac5d77dbdd..cd33cb57e2cf2eb29a83bcf5c931b3967065026f 100644
--- a/api/tests/common/test_scripts.py
+++ b/api/tests/common/test_scripts.py
@@ -42,3 +42,31 @@ def test_django_permissions_to_user_permissions(factories, command):
     assert user2.permission_settings is False
     assert user2.permission_library is True
     assert user2.permission_federation is True
+
+
+@pytest.mark.skip("Refactoring in progress")
+def test_migrate_to_user_libraries(factories, command):
+    user1 = factories["users.User"](is_superuser=False, with_actor=True)
+    user2 = factories["users.User"](is_superuser=True, with_actor=True)
+    factories["users.User"](is_superuser=True)
+    no_import_files = factories["music.TrackFile"].create_batch(size=5, library=None)
+    import_jobs = factories["music.ImportJob"].create_batch(
+        batch__submitted_by=user1, size=5, finished=True
+    )
+    # we delete libraries that are created automatically
+    for j in import_jobs:
+        j.track_file.library = None
+        j.track_file.save()
+    scripts.migrate_to_user_libraries.main(command)
+
+    # tracks with import jobs are bound to the importer's library
+    library = user1.actor.libraries.get(name="default")
+    assert list(library.files.order_by("id").values_list("id", flat=True)) == sorted(
+        [ij.track_file.pk for ij in import_jobs]
+    )
+
+    # tracks without import jobs are bound to first superuser
+    library = user2.actor.libraries.get(name="default")
+    assert list(library.files.order_by("id").values_list("id", flat=True)) == sorted(
+        [tf.pk for tf in no_import_files]
+    )
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index fc7e11947c84ed72bbf9446d22d08a6c41e737f9..8ad554d81ceaf13fd7089f60f8c4e3ed9bf1aa06 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -1,5 +1,7 @@
+import contextlib
 import datetime
 import io
+import os
 import PIL
 import random
 import shutil
@@ -10,6 +12,7 @@ import pytest
 import requests_mock
 from django.contrib.auth.models import AnonymousUser
 from django.core.cache import cache as django_cache
+from django.core.files import uploadedfile
 from django.utils import timezone
 from django.test import client
 from dynamic_preferences.registries import global_preferences_registry
@@ -272,3 +275,38 @@ def avatar():
     f.seek(0)
     yield f
     f.close()
+
+
+@pytest.fixture()
+def audio_file():
+    data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "music")
+    path = os.path.join(data_dir, "test.ogg")
+    assert os.path.exists(path)
+    with open(path, "rb") as f:
+        yield f
+
+
+@pytest.fixture()
+def uploaded_audio_file(audio_file):
+    yield uploadedfile.SimpleUploadedFile(
+        name=audio_file.name, content=audio_file.read()
+    )
+
+
+@pytest.fixture()
+def temp_signal(mocker):
+    """
+    Connect a temporary handler to a given signal. This is helpful to validate
+    a signal is dispatched properly, without mocking.
+    """
+
+    @contextlib.contextmanager
+    def connect(signal):
+        stub = mocker.stub()
+        signal.connect(stub)
+        try:
+            yield stub
+        finally:
+            signal.disconnect(stub)
+
+    return connect
diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py
index 0b99c93409a7ca1eb542fe2607d29a3b54c1293c..6ac244c69fac164b49171b3d0f8dba20f13aa02d 100644
--- a/api/tests/favorites/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -35,6 +35,7 @@ def test_user_can_get_his_favorites(api_request, factories, logged_in_client, cl
             "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"),
         }
     ]
+    expected[0]["track"]["is_playable"] = False
     assert response.status_code == 200
     assert response.data["results"] == expected
 
diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py
index 9c7bb70ecc43400215119a2cef9f8403c6a02783..7ca3371943af10b8cdd18adaf05b58e071e64af2 100644
--- a/api/tests/federation/test_activity.py
+++ b/api/tests/federation/test_activity.py
@@ -1,32 +1,7 @@
 
-from funkwhale_api.federation import activity, serializers
-
-
-def test_deliver(factories, r_mock, mocker, settings):
-    settings.CELERY_TASK_ALWAYS_EAGER = True
-    to = factories["federation.Actor"]()
-    mocker.patch("funkwhale_api.federation.actors.get_actor", return_value=to)
-    sender = factories["federation.Actor"]()
-    ac = {
-        "id": "http://test.federation/activity",
-        "type": "Create",
-        "actor": sender.url,
-        "object": {
-            "id": "http://test.federation/note",
-            "type": "Note",
-            "content": "Hello",
-        },
-    }
-
-    r_mock.post(to.inbox_url)
-
-    activity.deliver(ac, to=[to.url], on_behalf_of=sender)
-    request = r_mock.request_history[0]
+import pytest
 
-    assert r_mock.called is True
-    assert r_mock.call_count == 1
-    assert request.url == to.inbox_url
-    assert request.headers["content-type"] == "application/activity+json"
+from funkwhale_api.federation import activity, serializers, tasks
 
 
 def test_accept_follow(mocker, factories):
@@ -35,5 +10,125 @@ def test_accept_follow(mocker, factories):
     expected_accept = serializers.AcceptFollowSerializer(follow).data
     activity.accept_follow(follow)
     deliver.assert_called_once_with(
-        expected_accept, to=[follow.actor.url], on_behalf_of=follow.target
+        expected_accept, to=[follow.actor.fid], on_behalf_of=follow.target
     )
+
+
+def test_receive_validates_basic_attributes_and_stores_activity(factories, now, mocker):
+    mocked_dispatch = mocker.patch(
+        "funkwhale_api.federation.tasks.dispatch_inbox.delay"
+    )
+    local_actor = factories["users.User"]().create_actor()
+    remote_actor = factories["federation.Actor"]()
+    another_actor = factories["federation.Actor"]()
+    a = {
+        "@context": [],
+        "actor": remote_actor.fid,
+        "type": "Noop",
+        "id": "https://test.activity",
+        "to": [local_actor.fid],
+        "cc": [another_actor.fid, activity.PUBLIC_ADDRESS],
+    }
+
+    copy = activity.receive(activity=a, on_behalf_of=remote_actor)
+
+    assert copy.payload == a
+    assert copy.creation_date >= now
+    assert copy.actor == remote_actor
+    assert copy.fid == a["id"]
+    mocked_dispatch.assert_called_once_with(activity_id=copy.pk)
+
+    inbox_item = copy.inbox_items.get(actor__fid=local_actor.fid)
+    assert inbox_item.is_delivered is False
+
+
+def test_receive_invalid_data(factories):
+    remote_actor = factories["federation.Actor"]()
+    a = {"@context": [], "actor": remote_actor.fid, "id": "https://test.activity"}
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        activity.receive(activity=a, on_behalf_of=remote_actor)
+
+
+def test_receive_actor_mismatch(factories):
+    remote_actor = factories["federation.Actor"]()
+    a = {
+        "@context": [],
+        "type": "Noop",
+        "actor": "https://hello",
+        "id": "https://test.activity",
+    }
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        activity.receive(activity=a, on_behalf_of=remote_actor)
+
+
+def test_inbox_routing(mocker):
+    router = activity.InboxRouter()
+
+    handler = mocker.stub(name="handler")
+    router.connect({"type": "Follow"}, handler)
+
+    good_message = {"type": "Follow"}
+    router.dispatch(good_message, context={})
+
+    handler.assert_called_once_with(good_message, context={})
+
+
+@pytest.mark.parametrize(
+    "route,payload,expected",
+    [
+        ({"type": "Follow"}, {"type": "Follow"}, True),
+        ({"type": "Follow"}, {"type": "Noop"}, False),
+        ({"type": "Follow"}, {"type": "Follow", "id": "https://hello"}, True),
+    ],
+)
+def test_route_matching(route, payload, expected):
+    assert activity.match_route(route, payload) is expected
+
+
+def test_outbox_router_dispatch(mocker, factories, now):
+    router = activity.OutboxRouter()
+    recipient = factories["federation.Actor"]()
+    actor = factories["federation.Actor"]()
+    r1 = factories["federation.Actor"]()
+    r2 = factories["federation.Actor"]()
+    mocked_dispatch = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    def handler(context):
+        yield {
+            "payload": {
+                "type": "Noop",
+                "actor": actor.fid,
+                "summary": context["summary"],
+            },
+            "actor": actor,
+            "to": [r1],
+            "cc": [r2, activity.PUBLIC_ADDRESS],
+        }
+
+    router.connect({"type": "Noop"}, handler)
+    activities = router.dispatch({"type": "Noop"}, {"summary": "hello"})
+    a = activities[0]
+
+    mocked_dispatch.assert_called_once_with(
+        tasks.dispatch_outbox.delay, activity_id=a.pk
+    )
+
+    assert a.payload == {
+        "type": "Noop",
+        "actor": actor.fid,
+        "summary": "hello",
+        "to": [r1.fid],
+        "cc": [r2.fid, activity.PUBLIC_ADDRESS],
+    }
+    assert a.actor == actor
+    assert a.creation_date >= now
+    assert a.uuid is not None
+
+    for recipient, type in [(r1, "to"), (r2, "cc")]:
+        item = a.inbox_items.get(actor=recipient)
+        assert item.is_delivered is False
+        assert item.last_delivery_date is None
+        assert item.delivery_attempts == 0
+        assert item.type == type
diff --git a/api/tests/federation/test_actors.py b/api/tests/federation/test_actors.py
index 1244533de785ca227fdaf47258cd25aa10448703..f7d70f1771eb8c907424d8eca0ebe1c038c7cfc5 100644
--- a/api/tests/federation/test_actors.py
+++ b/api/tests/federation/test_actors.py
@@ -1,12 +1,9 @@
-import pendulum
 import pytest
 from django.urls import reverse
 from django.utils import timezone
 from rest_framework import exceptions
 
 from funkwhale_api.federation import actors, models, serializers, utils
-from funkwhale_api.music import models as music_models
-from funkwhale_api.music import tasks as music_tasks
 
 
 def test_actor_fetching(r_mock):
@@ -25,8 +22,8 @@ def test_actor_fetching(r_mock):
 def test_get_actor(factories, r_mock):
     actor = factories["federation.Actor"].build()
     payload = serializers.ActorSerializer(actor).data
-    r_mock.get(actor.url, json=payload)
-    new_actor = actors.get_actor(actor.url)
+    r_mock.get(actor.fid, json=payload)
+    new_actor = actors.get_actor(actor.fid)
 
     assert new_actor.pk is not None
     assert serializers.ActorSerializer(new_actor).data == payload
@@ -36,7 +33,7 @@ def test_get_actor_use_existing(factories, preferences, mocker):
     preferences["federation__actor_fetch_delay"] = 60
     actor = factories["federation.Actor"]()
     get_data = mocker.patch("funkwhale_api.federation.actors.get_actor_data")
-    new_actor = actors.get_actor(actor.url)
+    new_actor = actors.get_actor(actor.fid)
 
     assert new_actor == actor
     get_data.assert_not_called()
@@ -49,46 +46,13 @@ def test_get_actor_refresh(factories, preferences, mocker):
     # actor changed their username in the meantime
     payload["preferredUsername"] = "New me"
     mocker.patch("funkwhale_api.federation.actors.get_actor_data", return_value=payload)
-    new_actor = actors.get_actor(actor.url)
+    new_actor = actors.get_actor(actor.fid)
 
     assert new_actor == actor
     assert new_actor.last_fetch_date > actor.last_fetch_date
     assert new_actor.preferred_username == "New me"
 
 
-def test_get_library(db, settings, mocker):
-    mocker.patch(
-        "funkwhale_api.federation.keys.get_key_pair",
-        return_value=(b"private", b"public"),
-    )
-    expected = {
-        "preferred_username": "library",
-        "domain": settings.FEDERATION_HOSTNAME,
-        "type": "Person",
-        "name": "{}'s library".format(settings.FEDERATION_HOSTNAME),
-        "manually_approves_followers": True,
-        "public_key": "public",
-        "url": utils.full_url(
-            reverse("federation:instance-actors-detail", kwargs={"actor": "library"})
-        ),
-        "shared_inbox_url": utils.full_url(
-            reverse("federation:instance-actors-inbox", kwargs={"actor": "library"})
-        ),
-        "inbox_url": utils.full_url(
-            reverse("federation:instance-actors-inbox", kwargs={"actor": "library"})
-        ),
-        "outbox_url": utils.full_url(
-            reverse("federation:instance-actors-outbox", kwargs={"actor": "library"})
-        ),
-        "summary": "Bot account to federate with {}'s library".format(
-            settings.FEDERATION_HOSTNAME
-        ),
-    }
-    actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    for key, value in expected.items():
-        assert getattr(actor, key) == value
-
-
 def test_get_test(db, mocker, settings):
     mocker.patch(
         "funkwhale_api.federation.keys.get_key_pair",
@@ -101,7 +65,7 @@ def test_get_test(db, mocker, settings):
         "name": "{}'s test account".format(settings.FEDERATION_HOSTNAME),
         "manually_approves_followers": False,
         "public_key": "public",
-        "url": utils.full_url(
+        "fid": utils.full_url(
             reverse("federation:instance-actors-detail", kwargs={"actor": "test"})
         ),
         "shared_inbox_url": utils.full_url(
@@ -162,7 +126,7 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories):
     now = timezone.now()
     mocker.patch("django.utils.timezone.now", return_value=now)
     data = {
-        "actor": actor.url,
+        "actor": actor.fid,
         "type": "Create",
         "id": "http://test.federation/activity",
         "object": {
@@ -180,21 +144,21 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories):
         cc=[],
         summary=None,
         sensitive=False,
-        attributedTo=test_actor.url,
+        attributedTo=test_actor.fid,
         attachment=[],
-        to=[actor.url],
+        to=[actor.fid],
         url="https://{}/activities/note/{}".format(
             settings.FEDERATION_HOSTNAME, now.timestamp()
         ),
-        tag=[{"href": actor.url, "name": actor.mention_username, "type": "Mention"}],
+        tag=[{"href": actor.fid, "name": actor.full_username, "type": "Mention"}],
     )
     expected_activity = {
         "@context": serializers.AP_CONTEXT,
-        "actor": test_actor.url,
+        "actor": test_actor.fid,
         "id": "https://{}/activities/note/{}/activity".format(
             settings.FEDERATION_HOSTNAME, now.timestamp()
         ),
-        "to": actor.url,
+        "to": actor.fid,
         "type": "Create",
         "published": now.isoformat(),
         "object": expected_note,
@@ -203,14 +167,14 @@ def test_test_post_inbox_handles_create_note(settings, mocker, factories):
     actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor)
     deliver.assert_called_once_with(
         expected_activity,
-        to=[actor.url],
+        to=[actor.fid],
         on_behalf_of=actors.SYSTEM_ACTORS["test"].get_actor_instance(),
     )
 
 
 def test_getting_actor_instance_persists_in_db(db):
     test = actors.SYSTEM_ACTORS["test"].get_actor_instance()
-    from_db = models.Actor.objects.get(url=test.url)
+    from_db = models.Actor.objects.get(fid=test.fid)
 
     for f in test._meta.fields:
         assert getattr(from_db, f.name) == getattr(test, f.name)
@@ -247,17 +211,11 @@ def test_actor_system_conf(username, domain, expected, nodb_factories, settings)
     assert actor.system_conf == expected
 
 
-@pytest.mark.parametrize("value", [False, True])
-def test_library_actor_manually_approves_based_on_preference(value, preferences):
-    preferences["federation__music_needs_approval"] = value
-    library_conf = actors.SYSTEM_ACTORS["library"]
-    assert library_conf.manually_approves_followers is value
-
-
+@pytest.mark.skip("Refactoring in progress")
 def test_system_actor_handle(mocker, nodb_factories):
     handler = mocker.patch("funkwhale_api.federation.actors.TestActor.handle_create")
     actor = nodb_factories["federation.Actor"]()
-    activity = nodb_factories["federation.Activity"](type="Create", actor=actor.url)
+    activity = nodb_factories["federation.Activity"](type="Create", actor=actor)
     serializer = serializers.ActivitySerializer(data=activity)
     assert serializer.is_valid()
     actors.SYSTEM_ACTORS["test"].handle(activity, actor)
@@ -270,10 +228,10 @@ def test_test_actor_handles_follow(settings, mocker, factories):
     accept_follow = mocker.patch("funkwhale_api.federation.activity.accept_follow")
     test_actor = actors.SYSTEM_ACTORS["test"].get_actor_instance()
     data = {
-        "actor": actor.url,
+        "actor": actor.fid,
         "type": "Follow",
         "id": "http://test.federation/user#follows/267",
-        "object": test_actor.url,
+        "object": test_actor.fid,
     }
     actors.SYSTEM_ACTORS["test"].post_inbox(data, actor=actor)
     follow = models.Follow.objects.get(target=test_actor, approved=True)
@@ -282,7 +240,7 @@ def test_test_actor_handles_follow(settings, mocker, factories):
     deliver.assert_called_once_with(
         serializers.FollowSerializer(follow_back).data,
         on_behalf_of=test_actor,
-        to=[actor.url],
+        to=[actor.fid],
     )
 
 
@@ -299,215 +257,20 @@ def test_test_actor_handles_undo_follow(settings, mocker, factories):
         "@context": serializers.AP_CONTEXT,
         "type": "Undo",
         "id": follow_serializer.data["id"] + "/undo",
-        "actor": follow.actor.url,
+        "actor": follow.actor.fid,
         "object": follow_serializer.data,
     }
     expected_undo = {
         "@context": serializers.AP_CONTEXT,
         "type": "Undo",
         "id": reverse_follow_serializer.data["id"] + "/undo",
-        "actor": reverse_follow.actor.url,
+        "actor": reverse_follow.actor.fid,
         "object": reverse_follow_serializer.data,
     }
 
     actors.SYSTEM_ACTORS["test"].post_inbox(undo, actor=follow.actor)
     deliver.assert_called_once_with(
-        expected_undo, to=[follow.actor.url], on_behalf_of=test_actor
+        expected_undo, to=[follow.actor.fid], on_behalf_of=test_actor
     )
 
     assert models.Follow.objects.count() == 0
-
-
-def test_library_actor_handles_follow_manual_approval(preferences, mocker, factories):
-    preferences["federation__music_needs_approval"] = True
-    actor = factories["federation.Actor"]()
-    now = timezone.now()
-    mocker.patch("django.utils.timezone.now", return_value=now)
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    data = {
-        "actor": actor.url,
-        "type": "Follow",
-        "id": "http://test.federation/user#follows/267",
-        "object": library_actor.url,
-    }
-
-    library_actor.system_conf.post_inbox(data, actor=actor)
-    follow = library_actor.received_follows.first()
-
-    assert follow.actor == actor
-    assert follow.approved is None
-
-
-def test_library_actor_handles_follow_auto_approval(preferences, mocker, factories):
-    preferences["federation__music_needs_approval"] = False
-    actor = factories["federation.Actor"]()
-    mocker.patch("funkwhale_api.federation.activity.accept_follow")
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    data = {
-        "actor": actor.url,
-        "type": "Follow",
-        "id": "http://test.federation/user#follows/267",
-        "object": library_actor.url,
-    }
-    library_actor.system_conf.post_inbox(data, actor=actor)
-
-    follow = library_actor.received_follows.first()
-
-    assert follow.actor == actor
-    assert follow.approved is True
-
-
-def test_library_actor_handles_accept(mocker, factories):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    actor = factories["federation.Actor"]()
-    pending_follow = factories["federation.Follow"](
-        actor=library_actor, target=actor, approved=None
-    )
-    serializer = serializers.AcceptFollowSerializer(pending_follow)
-    library_actor.system_conf.post_inbox(serializer.data, actor=actor)
-
-    pending_follow.refresh_from_db()
-
-    assert pending_follow.approved is True
-
-
-def test_library_actor_handle_create_audio_no_library(mocker, factories):
-    # when we receive inbox create audio, we should not do anything
-    # if we don't have a configured library matching the sender
-    mocked_create = mocker.patch(
-        "funkwhale_api.federation.serializers.AudioSerializer.create"
-    )
-    actor = factories["federation.Actor"]()
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    data = {
-        "actor": actor.url,
-        "type": "Create",
-        "id": "http://test.federation/audio/create",
-        "object": {
-            "id": "https://batch.import",
-            "type": "Collection",
-            "totalItems": 2,
-            "items": factories["federation.Audio"].create_batch(size=2),
-        },
-    }
-    library_actor.system_conf.post_inbox(data, actor=actor)
-
-    mocked_create.assert_not_called()
-    models.LibraryTrack.objects.count() == 0
-
-
-def test_library_actor_handle_create_audio_no_library_enabled(mocker, factories):
-    # when we receive inbox create audio, we should not do anything
-    # if we don't have an enabled library
-    mocked_create = mocker.patch(
-        "funkwhale_api.federation.serializers.AudioSerializer.create"
-    )
-    disabled_library = factories["federation.Library"](federation_enabled=False)
-    actor = disabled_library.actor
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    data = {
-        "actor": actor.url,
-        "type": "Create",
-        "id": "http://test.federation/audio/create",
-        "object": {
-            "id": "https://batch.import",
-            "type": "Collection",
-            "totalItems": 2,
-            "items": factories["federation.Audio"].create_batch(size=2),
-        },
-    }
-    library_actor.system_conf.post_inbox(data, actor=actor)
-
-    mocked_create.assert_not_called()
-    models.LibraryTrack.objects.count() == 0
-
-
-def test_library_actor_handle_create_audio(mocker, factories):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    remote_library = factories["federation.Library"](federation_enabled=True)
-
-    data = {
-        "actor": remote_library.actor.url,
-        "type": "Create",
-        "id": "http://test.federation/audio/create",
-        "object": {
-            "id": "https://batch.import",
-            "type": "Collection",
-            "totalItems": 2,
-            "items": factories["federation.Audio"].create_batch(size=2),
-        },
-    }
-
-    library_actor.system_conf.post_inbox(data, actor=remote_library.actor)
-
-    lts = list(remote_library.tracks.order_by("id"))
-
-    assert len(lts) == 2
-
-    for i, a in enumerate(data["object"]["items"]):
-        lt = lts[i]
-        assert lt.pk is not None
-        assert lt.url == a["id"]
-        assert lt.library == remote_library
-        assert lt.audio_url == a["url"]["href"]
-        assert lt.audio_mimetype == a["url"]["mediaType"]
-        assert lt.metadata == a["metadata"]
-        assert lt.title == a["metadata"]["recording"]["title"]
-        assert lt.artist_name == a["metadata"]["artist"]["name"]
-        assert lt.album_title == a["metadata"]["release"]["title"]
-        assert lt.published_date == pendulum.parse(a["published"])
-
-
-def test_library_actor_handle_create_audio_autoimport(mocker, factories):
-    mocked_import = mocker.patch("funkwhale_api.common.utils.on_commit")
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    remote_library = factories["federation.Library"](
-        federation_enabled=True, autoimport=True
-    )
-
-    data = {
-        "actor": remote_library.actor.url,
-        "type": "Create",
-        "id": "http://test.federation/audio/create",
-        "object": {
-            "id": "https://batch.import",
-            "type": "Collection",
-            "totalItems": 2,
-            "items": factories["federation.Audio"].create_batch(size=2),
-        },
-    }
-
-    library_actor.system_conf.post_inbox(data, actor=remote_library.actor)
-
-    lts = list(remote_library.tracks.order_by("id"))
-
-    assert len(lts) == 2
-
-    for i, a in enumerate(data["object"]["items"]):
-        lt = lts[i]
-        assert lt.pk is not None
-        assert lt.url == a["id"]
-        assert lt.library == remote_library
-        assert lt.audio_url == a["url"]["href"]
-        assert lt.audio_mimetype == a["url"]["mediaType"]
-        assert lt.metadata == a["metadata"]
-        assert lt.title == a["metadata"]["recording"]["title"]
-        assert lt.artist_name == a["metadata"]["artist"]["name"]
-        assert lt.album_title == a["metadata"]["release"]["title"]
-        assert lt.published_date == pendulum.parse(a["published"])
-
-    batch = music_models.ImportBatch.objects.latest("id")
-
-    assert batch.jobs.count() == len(lts)
-    assert batch.source == "federation"
-    assert batch.submitted_by is None
-
-    for i, job in enumerate(batch.jobs.order_by("id")):
-        lt = lts[i]
-        assert job.library_track == lt
-        assert job.mbid == lt.mbid
-        assert job.source == lt.url
-
-        mocked_import.assert_any_call(
-            music_tasks.import_job_run.delay, import_job_id=job.pk, use_acoustid=False
-        )
diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3acc87310058cd4bc76d78d8dee3a974036af94
--- /dev/null
+++ b/api/tests/federation/test_api_serializers.py
@@ -0,0 +1,53 @@
+from funkwhale_api.federation import api_serializers
+from funkwhale_api.federation import serializers
+
+
+def test_library_serializer(factories):
+    library = factories["music.Library"](files_count=5678)
+    expected = {
+        "fid": library.fid,
+        "uuid": str(library.uuid),
+        "actor": serializers.APIActorSerializer(library.actor).data,
+        "name": library.name,
+        "description": library.description,
+        "creation_date": library.creation_date.isoformat().split("+")[0] + "Z",
+        "files_count": library.files_count,
+        "privacy_level": library.privacy_level,
+        "follow": None,
+    }
+
+    serializer = api_serializers.LibrarySerializer(library)
+
+    assert serializer.data == expected
+
+
+def test_library_serializer_with_follow(factories):
+    library = factories["music.Library"](files_count=5678)
+    follow = factories["federation.LibraryFollow"](target=library)
+
+    setattr(library, "_follows", [follow])
+    expected = {
+        "fid": library.fid,
+        "uuid": str(library.uuid),
+        "actor": serializers.APIActorSerializer(library.actor).data,
+        "name": library.name,
+        "description": library.description,
+        "creation_date": library.creation_date.isoformat().split("+")[0] + "Z",
+        "files_count": library.files_count,
+        "privacy_level": library.privacy_level,
+        "follow": api_serializers.NestedLibraryFollowSerializer(follow).data,
+    }
+
+    serializer = api_serializers.LibrarySerializer(library)
+
+    assert serializer.data == expected
+
+
+def test_library_serializer_validates_existing_follow(factories):
+    follow = factories["federation.LibraryFollow"]()
+    serializer = api_serializers.LibraryFollowSerializer(
+        data={"target": follow.target.uuid}, context={"actor": follow.actor}
+    )
+
+    assert serializer.is_valid() is False
+    assert "target" in serializer.errors
diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..44c7a882da87dea6a491bad9fc3f9f16d5ec78e4
--- /dev/null
+++ b/api/tests/federation/test_api_views.py
@@ -0,0 +1,51 @@
+from django.urls import reverse
+
+from funkwhale_api.federation import api_serializers
+from funkwhale_api.federation import serializers
+from funkwhale_api.federation import views
+
+
+def test_user_can_list_their_library_follows(factories, logged_in_api_client):
+    # followed by someont else
+    factories["federation.LibraryFollow"]()
+    follow = factories["federation.LibraryFollow"](
+        actor__user=logged_in_api_client.user
+    )
+    url = reverse("api:v1:federation:library-follows-list")
+    response = logged_in_api_client.get(url)
+
+    assert response.data["count"] == 1
+    assert response.data["results"][0]["uuid"] == str(follow.uuid)
+
+
+def test_user_can_scan_library_using_url(mocker, factories, logged_in_api_client):
+    library = factories["music.Library"]()
+    mocked_retrieve = mocker.patch(
+        "funkwhale_api.federation.utils.retrieve", return_value=library
+    )
+    url = reverse("api:v1:federation:libraries-scan")
+    response = logged_in_api_client.post(url, {"fid": library.fid})
+    assert mocked_retrieve.call_count == 1
+    args = mocked_retrieve.call_args
+    assert args[0] == (library.fid,)
+    assert args[1]["queryset"].model == views.MusicLibraryViewSet.queryset.model
+    assert args[1]["serializer_class"] == serializers.LibrarySerializer
+    assert response.status_code == 200
+    assert response.data["results"] == [api_serializers.LibrarySerializer(library).data]
+
+
+def test_can_follow_library(factories, logged_in_api_client, mocker):
+    dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
+    actor = logged_in_api_client.user.create_actor()
+    library = factories["music.Library"]()
+    url = reverse("api:v1:federation:library-follows-list")
+    response = logged_in_api_client.post(url, {"target": library.uuid})
+
+    assert response.status_code == 201
+
+    follow = library.received_follows.latest("id")
+
+    assert follow.approved is None
+    assert follow.actor == actor
+
+    dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow})
diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py
index 95cec5d2ac80d94a7b04de1dd5121c3d270b883b..0d761e70c33fb681f90f168227567836b48bbab6 100644
--- a/api/tests/federation/test_authentication.py
+++ b/api/tests/federation/test_authentication.py
@@ -36,4 +36,4 @@ def test_authenticate(factories, mocker, api_request):
 
     assert user.is_anonymous is True
     assert actor.public_key == public.decode("utf-8")
-    assert actor.url == actor_url
+    assert actor.fid == actor_url
diff --git a/api/tests/federation/test_library.py b/api/tests/federation/test_library.py
deleted file mode 100644
index 4e187e4792005e82cfe18cf9a066d31a10f8bbd3..0000000000000000000000000000000000000000
--- a/api/tests/federation/test_library.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from funkwhale_api.federation import library, serializers
-
-
-def test_library_scan_from_account_name(mocker, factories):
-    actor = factories["federation.Actor"](
-        preferred_username="library", domain="test.library"
-    )
-    get_resource_result = {"actor_url": actor.url}
-    get_resource = mocker.patch(
-        "funkwhale_api.federation.webfinger.get_resource",
-        return_value=get_resource_result,
-    )
-
-    actor_data = serializers.ActorSerializer(actor).data
-    actor_data["manuallyApprovesFollowers"] = False
-    actor_data["url"] = [
-        {
-            "type": "Link",
-            "name": "library",
-            "mediaType": "application/activity+json",
-            "href": "https://test.library",
-        }
-    ]
-    get_actor_data = mocker.patch(
-        "funkwhale_api.federation.actors.get_actor_data", return_value=actor_data
-    )
-
-    get_library_data_result = {"test": "test"}
-    get_library_data = mocker.patch(
-        "funkwhale_api.federation.library.get_library_data",
-        return_value=get_library_data_result,
-    )
-
-    result = library.scan_from_account_name("library@test.actor")
-
-    get_resource.assert_called_once_with("acct:library@test.actor")
-    get_actor_data.assert_called_once_with(actor.url)
-    get_library_data.assert_called_once_with(actor_data["url"][0]["href"])
-
-    assert result == {
-        "webfinger": get_resource_result,
-        "actor": actor_data,
-        "library": get_library_data_result,
-        "local": {"following": False, "awaiting_approval": False},
-    }
-
-
-def test_get_library_data(r_mock, factories):
-    actor = factories["federation.Actor"]()
-    url = "https://test.library"
-    conf = {"id": url, "items": [], "actor": actor, "page_size": 5}
-    data = serializers.PaginatedCollectionSerializer(conf).data
-    r_mock.get(url, json=data)
-
-    result = library.get_library_data(url)
-    for f in ["totalItems", "actor", "id", "type"]:
-        assert result[f] == data[f]
-
-
-def test_get_library_data_requires_authentication(r_mock, factories):
-    url = "https://test.library"
-    r_mock.get(url, status_code=403)
-    result = library.get_library_data(url)
-    assert result["errors"] == ["Permission denied while scanning library"]
diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py
index 61d0aea96dc9c66a3a96ce47230d62ceef6e5e57..eacc88c7c0dd68a98d337ee54c00cf271c7f50ed 100644
--- a/api/tests/federation/test_models.py
+++ b/api/tests/federation/test_models.py
@@ -20,12 +20,37 @@ def test_cannot_duplicate_follow(factories):
 
 def test_follow_federation_url(factories):
     follow = factories["federation.Follow"](local=True)
-    expected = "{}#follows/{}".format(follow.actor.url, follow.uuid)
-
-    assert follow.get_federation_url() == expected
-
-
-def test_library_model_unique_per_actor(factories):
-    library = factories["federation.Library"]()
-    with pytest.raises(db.IntegrityError):
-        factories["federation.Library"](actor=library.actor)
+    expected = "{}#follows/{}".format(follow.actor.fid, follow.uuid)
+
+    assert follow.get_federation_id() == expected
+
+
+def test_actor_get_quota(factories):
+    library = factories["music.Library"]()
+    factories["music.TrackFile"](
+        library=library,
+        import_status="pending",
+        audio_file__from_path=None,
+        audio_file__data=b"a",
+    )
+    factories["music.TrackFile"](
+        library=library,
+        import_status="skipped",
+        audio_file__from_path=None,
+        audio_file__data=b"aa",
+    )
+    factories["music.TrackFile"](
+        library=library,
+        import_status="errored",
+        audio_file__from_path=None,
+        audio_file__data=b"aaa",
+    )
+    factories["music.TrackFile"](
+        library=library,
+        import_status="finished",
+        audio_file__from_path=None,
+        audio_file__data=b"aaaa",
+    )
+    expected = {"total": 10, "pending": 1, "skipped": 2, "errored": 3, "finished": 4}
+
+    assert library.actor.get_current_usage() == expected
diff --git a/api/tests/federation/test_permissions.py b/api/tests/federation/test_permissions.py
deleted file mode 100644
index 75f76077cf4828049245e82a553f61e11e1e496b..0000000000000000000000000000000000000000
--- a/api/tests/federation/test_permissions.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from rest_framework.views import APIView
-
-from funkwhale_api.federation import actors, permissions
-
-
-def test_library_follower(factories, api_request, anonymous_user, preferences):
-    preferences["federation__music_needs_approval"] = True
-    view = APIView.as_view()
-    permission = permissions.LibraryFollower()
-    request = api_request.get("/")
-    setattr(request, "user", anonymous_user)
-    check = permission.has_permission(request, view)
-
-    assert check is False
-
-
-def test_library_follower_actor_non_follower(
-    factories, api_request, anonymous_user, preferences
-):
-    preferences["federation__music_needs_approval"] = True
-    actor = factories["federation.Actor"]()
-    view = APIView.as_view()
-    permission = permissions.LibraryFollower()
-    request = api_request.get("/")
-    setattr(request, "user", anonymous_user)
-    setattr(request, "actor", actor)
-    check = permission.has_permission(request, view)
-
-    assert check is False
-
-
-def test_library_follower_actor_follower_not_approved(
-    factories, api_request, anonymous_user, preferences
-):
-    preferences["federation__music_needs_approval"] = True
-    library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    follow = factories["federation.Follow"](target=library, approved=False)
-    view = APIView.as_view()
-    permission = permissions.LibraryFollower()
-    request = api_request.get("/")
-    setattr(request, "user", anonymous_user)
-    setattr(request, "actor", follow.actor)
-    check = permission.has_permission(request, view)
-
-    assert check is False
-
-
-def test_library_follower_actor_follower(
-    factories, api_request, anonymous_user, preferences
-):
-    preferences["federation__music_needs_approval"] = True
-    library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    follow = factories["federation.Follow"](target=library, approved=True)
-    view = APIView.as_view()
-    permission = permissions.LibraryFollower()
-    request = api_request.get("/")
-    setattr(request, "user", anonymous_user)
-    setattr(request, "actor", follow.actor)
-    check = permission.has_permission(request, view)
-
-    assert check is True
diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..e7ac88bba4be674a2cc89ab63296a2212f1c145c
--- /dev/null
+++ b/api/tests/federation/test_routes.py
@@ -0,0 +1,147 @@
+import pytest
+
+from funkwhale_api.federation import routes, serializers
+
+
+@pytest.mark.parametrize(
+    "route,handler",
+    [
+        ({"type": "Follow"}, routes.inbox_follow),
+        ({"type": "Accept"}, routes.inbox_accept),
+    ],
+)
+def test_inbox_routes(route, handler):
+    for r, h in routes.inbox.routes:
+        if r == route:
+            assert h == handler
+            return
+
+    assert False, "Inbox route {} not found".format(route)
+
+
+@pytest.mark.parametrize(
+    "route,handler",
+    [
+        ({"type": "Accept"}, routes.outbox_accept),
+        ({"type": "Follow"}, routes.outbox_follow),
+    ],
+)
+def test_outbox_routes(route, handler):
+    for r, h in routes.outbox.routes:
+        if r == route:
+            assert h == handler
+            return
+
+    assert False, "Outbox route {} not found".format(route)
+
+
+def test_inbox_follow_library_autoapprove(factories, mocker):
+    mocked_accept_follow = mocker.patch(
+        "funkwhale_api.federation.activity.accept_follow"
+    )
+
+    local_actor = factories["users.User"]().create_actor()
+    remote_actor = factories["federation.Actor"]()
+    library = factories["music.Library"](actor=local_actor, privacy_level="everyone")
+    ii = factories["federation.InboxItem"](actor=local_actor)
+
+    payload = {
+        "type": "Follow",
+        "id": "https://test.follow",
+        "actor": remote_actor.fid,
+        "object": library.fid,
+    }
+
+    routes.inbox_follow(
+        payload,
+        context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
+    )
+
+    follow = library.received_follows.latest("id")
+
+    assert follow.fid == payload["id"]
+    assert follow.actor == remote_actor
+    assert follow.approved is True
+
+    mocked_accept_follow.assert_called_once_with(follow)
+
+
+def test_inbox_follow_library_manual_approve(factories, mocker):
+    mocked_accept_follow = mocker.patch(
+        "funkwhale_api.federation.activity.accept_follow"
+    )
+
+    local_actor = factories["users.User"]().create_actor()
+    remote_actor = factories["federation.Actor"]()
+    library = factories["music.Library"](actor=local_actor, privacy_level="me")
+    ii = factories["federation.InboxItem"](actor=local_actor)
+
+    payload = {
+        "type": "Follow",
+        "id": "https://test.follow",
+        "actor": remote_actor.fid,
+        "object": library.fid,
+    }
+
+    routes.inbox_follow(
+        payload,
+        context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
+    )
+
+    follow = library.received_follows.latest("id")
+
+    assert follow.fid == payload["id"]
+    assert follow.actor == remote_actor
+    assert follow.approved is False
+
+    mocked_accept_follow.assert_not_called()
+
+
+def test_outbox_accept(factories, mocker):
+    remote_actor = factories["federation.Actor"]()
+    follow = factories["federation.LibraryFollow"](actor=remote_actor)
+
+    activity = list(routes.outbox_accept({"follow": follow}))[0]
+
+    serializer = serializers.AcceptFollowSerializer(
+        follow, context={"actor": follow.target.actor}
+    )
+    expected = serializer.data
+    expected["to"] = [follow.actor]
+
+    assert activity["payload"] == expected
+    assert activity["actor"] == follow.target.actor
+
+
+def test_inbox_accept(factories, mocker):
+    mocked_scan = mocker.patch("funkwhale_api.music.models.Library.schedule_scan")
+    local_actor = factories["users.User"]().create_actor()
+    remote_actor = factories["federation.Actor"]()
+    follow = factories["federation.LibraryFollow"](
+        actor=local_actor, target__actor=remote_actor
+    )
+    assert follow.approved is None
+    serializer = serializers.AcceptFollowSerializer(
+        follow, context={"actor": remote_actor}
+    )
+    ii = factories["federation.InboxItem"](actor=local_actor)
+    routes.inbox_accept(
+        serializer.data,
+        context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
+    )
+
+    follow.refresh_from_db()
+
+    assert follow.approved is True
+    mocked_scan.assert_called_once_with()
+
+
+def test_outbox_follow_library(factories, mocker):
+    follow = factories["federation.LibraryFollow"]()
+    activity = list(routes.outbox_follow({"follow": follow}))[0]
+    serializer = serializers.FollowSerializer(follow, context={"actor": follow.actor})
+    expected = serializer.data
+    expected["to"] = [follow.target.actor]
+
+    assert activity["payload"] == expected
+    assert activity["actor"] == follow.actor
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index b42c843ee6e402e19a8c32d88ba816fc2f5421fd..b67364a2aa671b57a8ea3e20a0794faaff37c940 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -1,8 +1,7 @@
-import pendulum
 import pytest
 from django.core.paginator import Paginator
 
-from funkwhale_api.federation import actors, models, serializers, utils
+from funkwhale_api.federation import activity, models, serializers, utils
 
 
 def test_actor_serializer_from_ap(db):
@@ -31,7 +30,7 @@ def test_actor_serializer_from_ap(db):
 
     actor = serializer.build()
 
-    assert actor.url == payload["id"]
+    assert actor.fid == payload["id"]
     assert actor.inbox_url == payload["inbox"]
     assert actor.outbox_url == payload["outbox"]
     assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"]
@@ -62,7 +61,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db):
 
     actor = serializer.build()
 
-    assert actor.url == payload["id"]
+    assert actor.fid == payload["id"]
     assert actor.inbox_url == payload["inbox"]
     assert actor.outbox_url == payload["outbox"]
     assert actor.followers_url == payload["followers"]
@@ -98,7 +97,7 @@ def test_actor_serializer_to_ap():
         "endpoints": {"sharedInbox": "https://test.federation/inbox"},
     }
     ac = models.Actor(
-        url=expected["id"],
+        fid=expected["id"],
         inbox_url=expected["inbox"],
         outbox_url=expected["outbox"],
         shared_inbox_url=expected["endpoints"]["sharedInbox"],
@@ -130,7 +129,7 @@ def test_webfinger_serializer():
         "aliases": ["https://test.federation/federation/instance/actor"],
     }
     actor = models.Actor(
-        url=expected["links"][0]["href"],
+        fid=expected["links"][0]["href"],
         preferred_username="service",
         domain="test.federation",
     )
@@ -149,10 +148,10 @@ def test_follow_serializer_to_ap(factories):
             "https://w3id.org/security/v1",
             {},
         ],
-        "id": follow.get_federation_url(),
+        "id": follow.get_federation_id(),
         "type": "Follow",
-        "actor": follow.actor.url,
-        "object": follow.target.url,
+        "actor": follow.actor.fid,
+        "object": follow.target.fid,
     }
 
     assert serializer.data == expected
@@ -165,8 +164,8 @@ def test_follow_serializer_save(factories):
     data = {
         "id": "https://test.follow",
         "type": "Follow",
-        "actor": actor.url,
-        "object": target.url,
+        "actor": actor.fid,
+        "object": target.fid,
     }
     serializer = serializers.FollowSerializer(data=data)
 
@@ -188,8 +187,8 @@ def test_follow_serializer_save_validates_on_context(factories):
     data = {
         "id": "https://test.follow",
         "type": "Follow",
-        "actor": actor.url,
-        "object": target.url,
+        "actor": actor.fid,
+        "object": target.fid,
     }
     serializer = serializers.FollowSerializer(
         data=data, context={"follow_actor": impostor, "follow_target": impostor}
@@ -210,9 +209,9 @@ def test_accept_follow_serializer_representation(factories):
             "https://w3id.org/security/v1",
             {},
         ],
-        "id": follow.get_federation_url() + "/accept",
+        "id": follow.get_federation_id() + "/accept",
         "type": "Accept",
-        "actor": follow.target.url,
+        "actor": follow.target.fid,
         "object": serializers.FollowSerializer(follow).data,
     }
 
@@ -230,9 +229,9 @@ def test_accept_follow_serializer_save(factories):
             "https://w3id.org/security/v1",
             {},
         ],
-        "id": follow.get_federation_url() + "/accept",
+        "id": follow.get_federation_id() + "/accept",
         "type": "Accept",
-        "actor": follow.target.url,
+        "actor": follow.target.fid,
         "object": serializers.FollowSerializer(follow).data,
     }
 
@@ -254,7 +253,7 @@ def test_accept_follow_serializer_validates_on_context(factories):
             "https://w3id.org/security/v1",
             {},
         ],
-        "id": follow.get_federation_url() + "/accept",
+        "id": follow.get_federation_id() + "/accept",
         "type": "Accept",
         "actor": impostor.url,
         "object": serializers.FollowSerializer(follow).data,
@@ -278,9 +277,9 @@ def test_undo_follow_serializer_representation(factories):
             "https://w3id.org/security/v1",
             {},
         ],
-        "id": follow.get_federation_url() + "/undo",
+        "id": follow.get_federation_id() + "/undo",
         "type": "Undo",
-        "actor": follow.actor.url,
+        "actor": follow.actor.fid,
         "object": serializers.FollowSerializer(follow).data,
     }
 
@@ -298,9 +297,9 @@ def test_undo_follow_serializer_save(factories):
             "https://w3id.org/security/v1",
             {},
         ],
-        "id": follow.get_federation_url() + "/undo",
+        "id": follow.get_federation_id() + "/undo",
         "type": "Undo",
-        "actor": follow.actor.url,
+        "actor": follow.actor.fid,
         "object": serializers.FollowSerializer(follow).data,
     }
 
@@ -321,7 +320,7 @@ def test_undo_follow_serializer_validates_on_context(factories):
             "https://w3id.org/security/v1",
             {},
         ],
-        "id": follow.get_federation_url() + "/undo",
+        "id": follow.get_federation_id() + "/undo",
         "type": "Undo",
         "actor": impostor.url,
         "object": serializers.FollowSerializer(follow).data,
@@ -355,7 +354,7 @@ def test_paginated_collection_serializer(factories):
         ],
         "type": "Collection",
         "id": conf["id"],
-        "actor": actor.url,
+        "actor": actor.fid,
         "totalItems": len(tfs),
         "current": conf["id"] + "?page=1",
         "last": conf["id"] + "?page=3",
@@ -452,7 +451,7 @@ def test_collection_page_serializer(factories):
         ],
         "type": "CollectionPage",
         "id": conf["id"] + "?page=2",
-        "actor": actor.url,
+        "actor": actor.fid,
         "totalItems": len(tfs),
         "partOf": conf["id"],
         "prev": conf["id"] + "?page=1",
@@ -472,58 +471,148 @@ def test_collection_page_serializer(factories):
     assert serializer.data == expected
 
 
-def test_activity_pub_audio_serializer_to_library_track(factories):
-    remote_library = factories["federation.Library"]()
-    audio = factories["federation.Audio"]()
-    serializer = serializers.AudioSerializer(
-        data=audio, context={"library": remote_library}
+def test_activity_pub_audio_serializer_to_library_track_no_duplicate(factories):
+    remote_library = factories["music.Library"]()
+    tf = factories["music.TrackFile"].build(library=remote_library)
+    data = serializers.AudioSerializer(tf).data
+    serializer1 = serializers.AudioSerializer(data=data)
+    serializer2 = serializers.AudioSerializer(data=data)
+
+    assert serializer1.is_valid(raise_exception=True) is True
+    assert serializer2.is_valid(raise_exception=True) is True
+
+    tf1 = serializer1.save()
+    tf2 = serializer2.save()
+
+    assert tf1 == tf2
+
+    assert tf1.library == remote_library
+    assert tf1.source == utils.full_url(tf.listen_url)
+    assert tf1.mimetype == tf.mimetype
+    assert tf1.bitrate == tf.bitrate
+    assert tf1.duration == tf.duration
+    assert tf1.size == tf.size
+    assert tf1.metadata == data
+    assert tf1.fid == tf.get_federation_id()
+    assert not tf1.audio_file
+
+
+def test_music_library_serializer_to_ap(factories):
+    library = factories["music.Library"]()
+    # pending, errored and skippednot included
+    factories["music.TrackFile"](import_status="pending")
+    factories["music.TrackFile"](import_status="errored")
+    factories["music.TrackFile"](import_status="finished")
+    serializer = serializers.LibrarySerializer(library)
+    expected = {
+        "@context": [
+            "https://www.w3.org/ns/activitystreams",
+            "https://w3id.org/security/v1",
+            {},
+        ],
+        "type": "Library",
+        "id": library.fid,
+        "name": library.name,
+        "summary": library.description,
+        "audience": "",
+        "actor": library.actor.fid,
+        "totalItems": 0,
+        "current": library.fid + "?page=1",
+        "last": library.fid + "?page=1",
+        "first": library.fid + "?page=1",
+    }
+
+    assert serializer.data == expected
+
+
+def test_music_library_serializer_from_public(factories, mocker):
+    actor = factories["federation.Actor"]()
+    retrieve = mocker.patch(
+        "funkwhale_api.federation.utils.retrieve", return_value=actor
     )
+    data = {
+        "@context": [
+            "https://www.w3.org/ns/activitystreams",
+            "https://w3id.org/security/v1",
+            {},
+        ],
+        "audience": "https://www.w3.org/ns/activitystreams#Public",
+        "name": "Hello",
+        "summary": "World",
+        "type": "Library",
+        "id": "https://library.id",
+        "actor": actor.fid,
+        "totalItems": 12,
+        "first": "https://library.id?page=1",
+        "last": "https://library.id?page=2",
+    }
+    serializer = serializers.LibrarySerializer(data=data)
 
     assert serializer.is_valid(raise_exception=True)
 
-    lt = serializer.save()
+    library = serializer.save()
 
-    assert lt.pk is not None
-    assert lt.url == audio["id"]
-    assert lt.library == remote_library
-    assert lt.audio_url == audio["url"]["href"]
-    assert lt.audio_mimetype == audio["url"]["mediaType"]
-    assert lt.metadata == audio["metadata"]
-    assert lt.title == audio["metadata"]["recording"]["title"]
-    assert lt.artist_name == audio["metadata"]["artist"]["name"]
-    assert lt.album_title == audio["metadata"]["release"]["title"]
-    assert lt.published_date == pendulum.parse(audio["published"])
+    assert library.actor == actor
+    assert library.fid == data["id"]
+    assert library.files_count == data["totalItems"]
+    assert library.privacy_level == "everyone"
+    assert library.name == "Hello"
+    assert library.description == "World"
+    retrieve.assert_called_once_with(
+        actor.fid,
+        queryset=actor.__class__,
+        serializer_class=serializers.ActorSerializer,
+    )
 
 
-def test_activity_pub_audio_serializer_to_library_track_no_duplicate(factories):
-    remote_library = factories["federation.Library"]()
-    audio = factories["federation.Audio"]()
-    serializer1 = serializers.AudioSerializer(
-        data=audio, context={"library": remote_library}
-    )
-    serializer2 = serializers.AudioSerializer(
-        data=audio, context={"library": remote_library}
+def test_music_library_serializer_from_private(factories, mocker):
+    actor = factories["federation.Actor"]()
+    retrieve = mocker.patch(
+        "funkwhale_api.federation.utils.retrieve", return_value=actor
     )
+    data = {
+        "@context": [
+            "https://www.w3.org/ns/activitystreams",
+            "https://w3id.org/security/v1",
+            {},
+        ],
+        "audience": "",
+        "name": "Hello",
+        "summary": "World",
+        "type": "Library",
+        "id": "https://library.id",
+        "actor": actor.fid,
+        "totalItems": 12,
+        "first": "https://library.id?page=1",
+        "last": "https://library.id?page=2",
+    }
+    serializer = serializers.LibrarySerializer(data=data)
 
-    assert serializer1.is_valid() is True
-    assert serializer2.is_valid() is True
+    assert serializer.is_valid(raise_exception=True)
 
-    lt1 = serializer1.save()
-    lt2 = serializer2.save()
+    library = serializer.save()
 
-    assert lt1 == lt2
-    assert models.LibraryTrack.objects.count() == 1
+    assert library.actor == actor
+    assert library.fid == data["id"]
+    assert library.files_count == data["totalItems"]
+    assert library.privacy_level == "me"
+    assert library.name == "Hello"
+    assert library.description == "World"
+    retrieve.assert_called_once_with(
+        actor.fid,
+        queryset=actor.__class__,
+        serializer_class=serializers.ActorSerializer,
+    )
 
 
 def test_activity_pub_audio_serializer_to_ap(factories):
     tf = factories["music.TrackFile"](
         mimetype="audio/mp3", bitrate=42, duration=43, size=44
     )
-    library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
     expected = {
         "@context": serializers.AP_CONTEXT,
         "type": "Audio",
-        "id": tf.get_federation_url(),
+        "id": tf.get_federation_id(),
         "name": tf.track.full_name,
         "published": tf.creation_date.isoformat(),
         "updated": tf.modification_date.isoformat(),
@@ -542,14 +631,14 @@ def test_activity_pub_audio_serializer_to_ap(factories):
             "bitrate": tf.bitrate,
         },
         "url": {
-            "href": utils.full_url(tf.path),
+            "href": utils.full_url(tf.listen_url),
             "type": "Link",
             "mediaType": "audio/mp3",
         },
-        "attributedTo": [library.url],
+        "library": tf.library.get_federation_id(),
     }
 
-    serializer = serializers.AudioSerializer(tf, context={"actor": library})
+    serializer = serializers.AudioSerializer(tf)
 
     assert serializer.data == expected
 
@@ -561,11 +650,10 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories):
         track__album__mbid=None,
         track__album__artist__mbid=None,
     )
-    library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
     expected = {
         "@context": serializers.AP_CONTEXT,
         "type": "Audio",
-        "id": tf.get_federation_url(),
+        "id": tf.get_federation_id(),
         "name": tf.track.full_name,
         "published": tf.creation_date.isoformat(),
         "updated": tf.modification_date.isoformat(),
@@ -573,116 +661,23 @@ def test_activity_pub_audio_serializer_to_ap_no_mbid(factories):
             "artist": {"name": tf.track.artist.name, "musicbrainz_id": None},
             "release": {"title": tf.track.album.title, "musicbrainz_id": None},
             "recording": {"title": tf.track.title, "musicbrainz_id": None},
-            "size": None,
+            "size": tf.size,
             "length": None,
             "bitrate": None,
         },
         "url": {
-            "href": utils.full_url(tf.path),
+            "href": utils.full_url(tf.listen_url),
             "type": "Link",
             "mediaType": "audio/mp3",
         },
-        "attributedTo": [library.url],
+        "library": tf.library.fid,
     }
 
-    serializer = serializers.AudioSerializer(tf, context={"actor": library})
+    serializer = serializers.AudioSerializer(tf)
 
     assert serializer.data == expected
 
 
-def test_collection_serializer_to_ap(factories):
-    tf1 = factories["music.TrackFile"](mimetype="audio/mp3")
-    tf2 = factories["music.TrackFile"](mimetype="audio/ogg")
-    library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    expected = {
-        "@context": serializers.AP_CONTEXT,
-        "id": "https://test.id",
-        "actor": library.url,
-        "totalItems": 2,
-        "type": "Collection",
-        "items": [
-            serializers.AudioSerializer(
-                tf1, context={"actor": library, "include_ap_context": False}
-            ).data,
-            serializers.AudioSerializer(
-                tf2, context={"actor": library, "include_ap_context": False}
-            ).data,
-        ],
-    }
-
-    collection = {
-        "id": expected["id"],
-        "actor": library,
-        "items": [tf1, tf2],
-        "item_serializer": serializers.AudioSerializer,
-    }
-    serializer = serializers.CollectionSerializer(
-        collection, context={"actor": library, "id": "https://test.id"}
-    )
-
-    assert serializer.data == expected
-
-
-def test_api_library_create_serializer_save(factories, r_mock):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    actor = factories["federation.Actor"]()
-    follow = factories["federation.Follow"](target=actor, actor=library_actor)
-    actor_data = serializers.ActorSerializer(actor).data
-    actor_data["url"] = [
-        {"href": "https://test.library", "name": "library", "type": "Link"}
-    ]
-    library_conf = {
-        "id": "https://test.library",
-        "items": range(10),
-        "actor": actor,
-        "page_size": 5,
-    }
-    library_data = serializers.PaginatedCollectionSerializer(library_conf).data
-    r_mock.get(actor.url, json=actor_data)
-    r_mock.get("https://test.library", json=library_data)
-    data = {
-        "actor": actor.url,
-        "autoimport": False,
-        "federation_enabled": True,
-        "download_files": False,
-    }
-
-    serializer = serializers.APILibraryCreateSerializer(data=data)
-    assert serializer.is_valid(raise_exception=True) is True
-    library = serializer.save()
-    follow = models.Follow.objects.get(target=actor, actor=library_actor, approved=None)
-
-    assert library.autoimport is data["autoimport"]
-    assert library.federation_enabled is data["federation_enabled"]
-    assert library.download_files is data["download_files"]
-    assert library.tracks_count == 10
-    assert library.actor == actor
-    assert library.follow == follow
-
-
-def test_tapi_library_track_serializer_not_imported(factories):
-    lt = factories["federation.LibraryTrack"]()
-    serializer = serializers.APILibraryTrackSerializer(lt)
-
-    assert serializer.get_status(lt) == "not_imported"
-
-
-def test_tapi_library_track_serializer_imported(factories):
-    tf = factories["music.TrackFile"](federation=True)
-    lt = tf.library_track
-    serializer = serializers.APILibraryTrackSerializer(lt)
-
-    assert serializer.get_status(lt) == "imported"
-
-
-def test_tapi_library_track_serializer_import_pending(factories):
-    job = factories["music.ImportJob"](federation=True, status="pending")
-    lt = job.library_track
-    serializer = serializers.APILibraryTrackSerializer(lt)
-
-    assert serializer.get_status(lt) == "import_pending"
-
-
 def test_local_actor_serializer_to_ap(factories):
     expected = {
         "@context": [
@@ -708,7 +703,7 @@ def test_local_actor_serializer_to_ap(factories):
         "endpoints": {"sharedInbox": "https://test.federation/inbox"},
     }
     ac = models.Actor.objects.create(
-        url=expected["id"],
+        fid=expected["id"],
         inbox_url=expected["inbox"],
         outbox_url=expected["outbox"],
         shared_inbox_url=expected["endpoints"]["sharedInbox"],
@@ -734,3 +729,45 @@ def test_local_actor_serializer_to_ap(factories):
     serializer = serializers.ActorSerializer(ac)
 
     assert serializer.data == expected
+
+
+def test_activity_serializer_clean_recipients_empty(db):
+    s = serializers.BaseActivitySerializer()
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({"to": []})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({"cc": []})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({"to": ["nope"]})
+
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({"cc": ["nope"]})
+
+
+def test_activity_serializer_clean_recipients(factories):
+    r1, r2, r3 = factories["federation.Actor"].create_batch(size=3)
+
+    s = serializers.BaseActivitySerializer()
+
+    expected = {"to": [r1, r2], "cc": [r3, activity.PUBLIC_ADDRESS]}
+
+    assert (
+        s.validate_recipients(
+            {"to": [r1.fid, r2.fid], "cc": [r3.fid, activity.PUBLIC_ADDRESS]}
+        )
+        == expected
+    )
+
+
+def test_activity_serializer_clean_recipients_local(factories):
+    r = factories["federation.Actor"]()
+
+    s = serializers.BaseActivitySerializer(context={"local_recipients": True})
+    with pytest.raises(serializers.serializers.ValidationError):
+        s.validate_recipients({"to": [r]})
diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py
index bc10eae9564cb4a6ae89882ba6478c92ad9137fd..216163277d8be3043008308424f3879ff2890f62 100644
--- a/api/tests/federation/test_tasks.py
+++ b/api/tests/federation/test_tasks.py
@@ -1,155 +1,240 @@
 import datetime
 import os
 import pathlib
+import pytest
 
-from django.core.paginator import Paginator
 from django.utils import timezone
 
-from funkwhale_api.federation import serializers, tasks
+from funkwhale_api.federation import tasks
 
 
-def test_scan_library_does_nothing_if_federation_disabled(mocker, factories):
-    library = factories["federation.Library"](federation_enabled=False)
-    tasks.scan_library(library_id=library.pk)
+def test_clean_federation_music_cache_if_no_listen(preferences, factories):
+    preferences["federation__music_cache_duration"] = 60
+    remote_library = factories["music.Library"]()
+    tf1 = factories["music.TrackFile"](
+        library=remote_library, accessed_date=timezone.now()
+    )
+    tf2 = factories["music.TrackFile"](
+        library=remote_library,
+        accessed_date=timezone.now() - datetime.timedelta(minutes=61),
+    )
+    tf3 = factories["music.TrackFile"](library=remote_library, accessed_date=None)
+    path1 = tf1.audio_file.path
+    path2 = tf2.audio_file.path
+    path3 = tf3.audio_file.path
 
-    assert library.tracks.count() == 0
+    tasks.clean_music_cache()
 
+    tf1.refresh_from_db()
+    tf2.refresh_from_db()
+    tf3.refresh_from_db()
 
-def test_scan_library_page_does_nothing_if_federation_disabled(mocker, factories):
-    library = factories["federation.Library"](federation_enabled=False)
-    tasks.scan_library_page(library_id=library.pk, page_url=None)
+    assert bool(tf1.audio_file) is True
+    assert bool(tf2.audio_file) is False
+    assert bool(tf3.audio_file) is False
+    assert os.path.exists(path1) is True
+    assert os.path.exists(path2) is False
+    assert os.path.exists(path3) is False
 
-    assert library.tracks.count() == 0
 
+def test_clean_federation_music_cache_orphaned(settings, preferences, factories):
+    preferences["federation__music_cache_duration"] = 60
+    path = os.path.join(settings.MEDIA_ROOT, "federation_cache", "tracks")
+    keep_path = os.path.join(os.path.join(path, "1a", "b2"), "keep.ogg")
+    remove_path = os.path.join(os.path.join(path, "c3", "d4"), "remove.ogg")
+    os.makedirs(os.path.dirname(keep_path), exist_ok=True)
+    os.makedirs(os.path.dirname(remove_path), exist_ok=True)
+    pathlib.Path(keep_path).touch()
+    pathlib.Path(remove_path).touch()
+    tf = factories["music.TrackFile"](
+        accessed_date=timezone.now(), audio_file__path=keep_path
+    )
+
+    tasks.clean_music_cache()
 
-def test_scan_library_fetches_page_and_calls_scan_page(mocker, factories, r_mock):
-    now = timezone.now()
-    library = factories["federation.Library"](federation_enabled=True)
-    collection_conf = {
-        "actor": library.actor,
-        "id": library.url,
-        "page_size": 10,
-        "items": range(10),
-    }
-    collection = serializers.PaginatedCollectionSerializer(collection_conf)
-    scan_page = mocker.patch("funkwhale_api.federation.tasks.scan_library_page.delay")
-    r_mock.get(collection_conf["id"], json=collection.data)
-    tasks.scan_library(library_id=library.pk)
+    tf.refresh_from_db()
+
+    assert bool(tf.audio_file) is True
+    assert os.path.exists(tf.audio_file.path) is True
+    assert os.path.exists(remove_path) is False
 
-    scan_page.assert_called_once_with(
-        library_id=library.id, page_url=collection.data["first"], until=None
-    )
-    library.refresh_from_db()
-    assert library.fetched_date > now
 
+def test_handle_in(factories, mocker, now):
+    mocked_dispatch = mocker.patch("funkwhale_api.federation.routes.inbox.dispatch")
 
-def test_scan_page_fetches_page_and_creates_tracks(mocker, factories, r_mock):
-    library = factories["federation.Library"](federation_enabled=True)
-    tfs = factories["music.TrackFile"].create_batch(size=5)
-    page_conf = {
-        "actor": library.actor,
-        "id": library.url,
-        "page": Paginator(tfs, 5).page(1),
-        "item_serializer": serializers.AudioSerializer,
-    }
-    page = serializers.CollectionPageSerializer(page_conf)
-    r_mock.get(page.data["id"], json=page.data)
+    r1 = factories["users.User"](with_actor=True).actor
+    r2 = factories["users.User"](with_actor=True).actor
+    a = factories["federation.Activity"](payload={"hello": "world"})
+    ii1 = factories["federation.InboxItem"](activity=a, actor=r1)
+    ii2 = factories["federation.InboxItem"](activity=a, actor=r2)
+    tasks.dispatch_inbox(activity_id=a.pk)
+
+    mocked_dispatch.assert_called_once_with(
+        a.payload, context={"actor": a.actor, "inbox_items": [ii1, ii2]}
+    )
 
-    tasks.scan_library_page(library_id=library.pk, page_url=page.data["id"])
+    ii1.refresh_from_db()
+    ii2.refresh_from_db()
 
-    lts = list(library.tracks.all().order_by("-published_date"))
-    assert len(lts) == 5
+    assert ii1.is_delivered is True
+    assert ii2.is_delivered is True
+    assert ii1.last_delivery_date == now
+    assert ii2.last_delivery_date == now
 
 
-def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock):
-    patched_scan = mocker.patch(
-        "funkwhale_api.federation.tasks.scan_library_page.delay"
+def test_handle_in_error(factories, mocker, now):
+    mocker.patch(
+        "funkwhale_api.federation.routes.inbox.dispatch", side_effect=Exception()
     )
-    library = factories["federation.Library"](federation_enabled=True)
-    tfs = factories["music.TrackFile"].create_batch(size=1)
-    page_conf = {
-        "actor": library.actor,
-        "id": library.url,
-        "page": Paginator(tfs, 3).page(1),
-        "item_serializer": serializers.AudioSerializer,
-    }
-    page = serializers.CollectionPageSerializer(page_conf)
-    data = page.data
-    data["next"] = data["id"]
-    r_mock.get(page.data["id"], json=data)
+    r1 = factories["users.User"](with_actor=True).actor
+    r2 = factories["users.User"](with_actor=True).actor
 
-    tasks.scan_library_page(library_id=library.pk, page_url=data["id"])
-    patched_scan.assert_not_called()
+    a = factories["federation.Activity"](payload={"hello": "world"})
+    factories["federation.InboxItem"](activity=a, actor=r1)
+    factories["federation.InboxItem"](activity=a, actor=r2)
 
+    with pytest.raises(Exception):
+        tasks.dispatch_inbox(activity_id=a.pk)
 
-def test_scan_page_stops_once_until_is_reached(mocker, factories, r_mock):
-    library = factories["federation.Library"](federation_enabled=True)
-    tfs = list(reversed(factories["music.TrackFile"].create_batch(size=5)))
-    page_conf = {
-        "actor": library.actor,
-        "id": library.url,
-        "page": Paginator(tfs, 3).page(1),
-        "item_serializer": serializers.AudioSerializer,
-    }
-    page = serializers.CollectionPageSerializer(page_conf)
-    r_mock.get(page.data["id"], json=page.data)
+    assert a.inbox_items.filter(is_delivered=False).count() == 2
 
-    tasks.scan_library_page(
-        library_id=library.pk, page_url=page.data["id"], until=tfs[1].creation_date
+
+def test_dispatch_outbox_to_inbox(factories, mocker):
+    mocked_inbox = mocker.patch("funkwhale_api.federation.tasks.dispatch_inbox.delay")
+    mocked_deliver_to_remote_inbox = mocker.patch(
+        "funkwhale_api.federation.tasks.deliver_to_remote_inbox.delay"
+    )
+    activity = factories["federation.Activity"](actor__local=True)
+    factories["federation.InboxItem"](activity=activity, actor__local=True)
+    remote_ii = factories["federation.InboxItem"](
+        activity=activity,
+        actor__shared_inbox_url=None,
+        actor__inbox_url="https://test.inbox",
+    )
+    tasks.dispatch_outbox(activity_id=activity.pk)
+    mocked_inbox.assert_called_once_with(activity_id=activity.pk)
+    mocked_deliver_to_remote_inbox.assert_called_once_with(
+        activity_id=activity.pk, inbox_url=remote_ii.actor.inbox_url
     )
 
-    lts = list(library.tracks.all().order_by("-published_date"))
-    assert len(lts) == 2
-    for i, tf in enumerate(tfs[:1]):
-        assert tf.creation_date == lts[i].published_date
 
+def test_dispatch_outbox_to_shared_inbox_url(factories, mocker):
+    mocked_deliver_to_remote_inbox = mocker.patch(
+        "funkwhale_api.federation.tasks.deliver_to_remote_inbox.delay"
+    )
+    activity = factories["federation.Activity"](actor__local=True)
+    # shared inbox
+    remote_ii_shared1 = factories["federation.InboxItem"](
+        activity=activity, actor__shared_inbox_url="https://shared.inbox"
+    )
+    # another on the same shared inbox
+    factories["federation.InboxItem"](
+        activity=activity, actor__shared_inbox_url="https://shared.inbox"
+    )
+    # one on a dedicated inbox
+    remote_ii_single = factories["federation.InboxItem"](
+        activity=activity,
+        actor__shared_inbox_url=None,
+        actor__inbox_url="https://single.inbox",
+    )
+    tasks.dispatch_outbox(activity_id=activity.pk)
 
-def test_clean_federation_music_cache_if_no_listen(preferences, factories):
-    preferences["federation__music_cache_duration"] = 60
-    lt1 = factories["federation.LibraryTrack"](with_audio_file=True)
-    lt2 = factories["federation.LibraryTrack"](with_audio_file=True)
-    lt3 = factories["federation.LibraryTrack"](with_audio_file=True)
-    factories["music.TrackFile"](accessed_date=timezone.now(), library_track=lt1)
-    factories["music.TrackFile"](
-        accessed_date=timezone.now() - datetime.timedelta(minutes=61), library_track=lt2
-    )
-    factories["music.TrackFile"](accessed_date=None, library_track=lt3)
-    path1 = lt1.audio_file.path
-    path2 = lt2.audio_file.path
-    path3 = lt3.audio_file.path
+    assert mocked_deliver_to_remote_inbox.call_count == 2
+    mocked_deliver_to_remote_inbox.assert_any_call(
+        activity_id=activity.pk,
+        shared_inbox_url=remote_ii_shared1.actor.shared_inbox_url,
+    )
+    mocked_deliver_to_remote_inbox.assert_any_call(
+        activity_id=activity.pk, inbox_url=remote_ii_single.actor.inbox_url
+    )
 
-    tasks.clean_music_cache()
 
-    lt1.refresh_from_db()
-    lt2.refresh_from_db()
-    lt3.refresh_from_db()
+def test_deliver_to_remote_inbox_inbox_url(factories, r_mock):
+    activity = factories["federation.Activity"]()
+    url = "https://test.shared/"
+    r_mock.post(url)
 
-    assert bool(lt1.audio_file) is True
-    assert bool(lt2.audio_file) is False
-    assert bool(lt3.audio_file) is False
-    assert os.path.exists(path1) is True
-    assert os.path.exists(path2) is False
-    assert os.path.exists(path3) is False
+    tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url)
 
+    request = r_mock.request_history[0]
 
-def test_clean_federation_music_cache_orphaned(settings, preferences, factories):
-    preferences["federation__music_cache_duration"] = 60
-    path = os.path.join(settings.MEDIA_ROOT, "federation_cache")
-    keep_path = os.path.join(os.path.join(path, "1a", "b2"), "keep.ogg")
-    remove_path = os.path.join(os.path.join(path, "c3", "d4"), "remove.ogg")
-    os.makedirs(os.path.dirname(keep_path), exist_ok=True)
-    os.makedirs(os.path.dirname(remove_path), exist_ok=True)
-    pathlib.Path(keep_path).touch()
-    pathlib.Path(remove_path).touch()
-    lt = factories["federation.LibraryTrack"](
-        with_audio_file=True, audio_file__path=keep_path
+    assert r_mock.called is True
+    assert r_mock.call_count == 1
+    assert request.url == url
+    assert request.headers["content-type"] == "application/activity+json"
+    assert request.json() == activity.payload
+
+
+def test_deliver_to_remote_inbox_shared_inbox_url(factories, r_mock):
+    activity = factories["federation.Activity"]()
+    url = "https://test.shared/"
+    r_mock.post(url)
+
+    tasks.deliver_to_remote_inbox(activity_id=activity.pk, shared_inbox_url=url)
+
+    request = r_mock.request_history[0]
+
+    assert r_mock.called is True
+    assert r_mock.call_count == 1
+    assert request.url == url
+    assert request.headers["content-type"] == "application/activity+json"
+    assert request.json() == activity.payload
+
+
+def test_deliver_to_remote_inbox_success_shared_inbox_marks_inbox_items_as_delivered(
+    factories, r_mock, now
+):
+    activity = factories["federation.Activity"]()
+    url = "https://test.shared/"
+    r_mock.post(url)
+    ii = factories["federation.InboxItem"](
+        activity=activity, actor__shared_inbox_url=url
     )
-    factories["music.TrackFile"](library_track=lt, accessed_date=timezone.now())
+    other_ii = factories["federation.InboxItem"](
+        activity=activity, actor__shared_inbox_url="https://other.url"
+    )
+    tasks.deliver_to_remote_inbox(activity_id=activity.pk, shared_inbox_url=url)
+
+    ii.refresh_from_db()
+    other_ii.refresh_from_db()
+
+    assert ii.is_delivered is True
+    assert ii.last_delivery_date == now
+    assert other_ii.is_delivered is False
+    assert other_ii.last_delivery_date is None
+
+
+def test_deliver_to_remote_inbox_success_single_inbox_marks_inbox_items_as_delivered(
+    factories, r_mock, now
+):
+    activity = factories["federation.Activity"]()
+    url = "https://test.single/"
+    r_mock.post(url)
+    ii = factories["federation.InboxItem"](activity=activity, actor__inbox_url=url)
+    other_ii = factories["federation.InboxItem"](
+        activity=activity, actor__inbox_url="https://other.url"
+    )
+    tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url)
 
-    tasks.clean_music_cache()
+    ii.refresh_from_db()
+    other_ii.refresh_from_db()
 
-    lt.refresh_from_db()
+    assert ii.is_delivered is True
+    assert ii.last_delivery_date == now
+    assert other_ii.is_delivered is False
+    assert other_ii.last_delivery_date is None
 
-    assert bool(lt.audio_file) is True
-    assert os.path.exists(lt.audio_file.path) is True
-    assert os.path.exists(remove_path) is False
+
+def test_deliver_to_remote_inbox_error(factories, r_mock, now):
+    activity = factories["federation.Activity"]()
+    url = "https://test.single/"
+    r_mock.post(url, status_code=404)
+    ii = factories["federation.InboxItem"](activity=activity, actor__inbox_url=url)
+    with pytest.raises(tasks.RequestException):
+        tasks.deliver_to_remote_inbox(activity_id=activity.pk, inbox_url=url)
+
+    ii.refresh_from_db()
+
+    assert ii.is_delivered is False
+    assert ii.last_delivery_date == now
+    assert ii.delivery_attempts == 1
diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py
index dbebe0fdc2a5b074c7d392b838df7027b77f1836..e89c52543e92df3862d0b64b196084bd2a2c7a02 100644
--- a/api/tests/federation/test_utils.py
+++ b/api/tests/federation/test_utils.py
@@ -1,3 +1,4 @@
+from rest_framework import serializers
 import pytest
 
 from funkwhale_api.federation import utils
@@ -50,3 +51,41 @@ def test_extract_headers_from_meta():
         "User-Agent": "http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)",
     }
     assert cleaned_headers == expected
+
+
+def test_retrieve(r_mock):
+    fid = "https://some.url"
+    m = r_mock.get(fid, json={"hello": "world"})
+    result = utils.retrieve(fid)
+
+    assert result == {"hello": "world"}
+    assert m.request_history[-1].headers["Accept"] == "application/activity+json"
+
+
+def test_retrieve_with_actor(r_mock, factories):
+    actor = factories["federation.Actor"]()
+    fid = "https://some.url"
+    m = r_mock.get(fid, json={"hello": "world"})
+    result = utils.retrieve(fid, actor=actor)
+
+    assert result == {"hello": "world"}
+    assert m.request_history[-1].headers["Accept"] == "application/activity+json"
+    assert m.request_history[-1].headers["Signature"] is not None
+
+
+def test_retrieve_with_queryset(factories):
+    actor = factories["federation.Actor"]()
+
+    assert utils.retrieve(actor.fid, queryset=actor.__class__)
+
+
+def test_retrieve_with_serializer(r_mock):
+    class S(serializers.Serializer):
+        def create(self, validated_data):
+            return {"persisted": "object"}
+
+    fid = "https://some.url"
+    r_mock.get(fid, json={"hello": "world"})
+    result = utils.retrieve(fid, serializer_class=S)
+
+    assert result == {"persisted": "object"}
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index a99c71ffb02a926fcccba8b8fc572840a2420749..54534e93b1b9e419e0845bbc0040cce43d3c08ed 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -1,29 +1,8 @@
 import pytest
 from django.core.paginator import Paginator
 from django.urls import reverse
-from django.utils import timezone
-
-from funkwhale_api.federation import (
-    activity,
-    actors,
-    models,
-    serializers,
-    utils,
-    views,
-    webfinger,
-)
-from funkwhale_api.music import tasks as music_tasks
-
-
-@pytest.mark.parametrize(
-    "view,permissions",
-    [
-        (views.LibraryViewSet, ["federation"]),
-        (views.LibraryTrackViewSet, ["federation"]),
-    ],
-)
-def test_permissions(assert_user_permission, view, permissions):
-    assert_user_permission(view, permissions)
+
+from funkwhale_api.federation import actors, serializers, webfinger
 
 
 @pytest.mark.parametrize("system_actor", actors.SYSTEM_ACTORS.keys())
@@ -39,25 +18,6 @@ def test_instance_actors(system_actor, db, api_client):
     assert response.data == serializer.data
 
 
-@pytest.mark.parametrize(
-    "route,kwargs",
-    [
-        ("instance-actors-outbox", {"actor": "library"}),
-        ("instance-actors-inbox", {"actor": "library"}),
-        ("instance-actors-detail", {"actor": "library"}),
-        ("well-known-webfinger", {}),
-    ],
-)
-def test_instance_endpoints_405_if_federation_disabled(
-    authenticated_actor, db, preferences, api_client, route, kwargs
-):
-    preferences["federation__enabled"] = False
-    url = reverse("federation:{}".format(route), kwargs=kwargs)
-    response = api_client.get(url)
-
-    assert response.status_code == 405
-
-
 def test_wellknown_webfinger_validates_resource(db, api_client, settings, mocker):
     clean = mocker.spy(webfinger, "clean_resource")
     url = reverse("federation:well-known-webfinger")
@@ -110,341 +70,114 @@ def test_wellknown_nodeinfo_disabled(db, preferences, api_client):
     assert response.status_code == 404
 
 
-def test_audio_file_list_requires_authenticated_actor(db, preferences, api_client):
-    preferences["federation__music_needs_approval"] = True
-    url = reverse("federation:music:files-list")
-    response = api_client.get(url)
-
-    assert response.status_code == 403
-
-
-def test_audio_file_list_actor_no_page(db, preferences, api_client, factories):
-    preferences["federation__music_needs_approval"] = False
-    preferences["federation__collection_page_size"] = 2
-    library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    tfs = factories["music.TrackFile"].create_batch(size=5)
-    conf = {
-        "id": utils.full_url(reverse("federation:music:files-list")),
-        "page_size": 2,
-        "items": list(reversed(tfs)),  # we order by -creation_date
-        "item_serializer": serializers.AudioSerializer,
-        "actor": library,
-    }
-    expected = serializers.PaginatedCollectionSerializer(conf).data
-    url = reverse("federation:music:files-list")
-    response = api_client.get(url)
-
-    assert response.status_code == 200
-    assert response.data == expected
-
-
-def test_audio_file_list_actor_page(db, preferences, api_client, factories):
-    preferences["federation__music_needs_approval"] = False
-    preferences["federation__collection_page_size"] = 2
-    library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    tfs = factories["music.TrackFile"].create_batch(size=5)
-    conf = {
-        "id": utils.full_url(reverse("federation:music:files-list")),
-        "page": Paginator(list(reversed(tfs)), 2).page(2),
-        "item_serializer": serializers.AudioSerializer,
-        "actor": library,
-    }
-    expected = serializers.CollectionPageSerializer(conf).data
-    url = reverse("federation:music:files-list")
-    response = api_client.get(url, data={"page": 2})
-
-    assert response.status_code == 200
-    assert response.data == expected
-
-
-def test_audio_file_list_actor_page_exclude_federated_files(
-    db, preferences, api_client, factories
-):
-    preferences["federation__music_needs_approval"] = False
-    factories["music.TrackFile"].create_batch(size=5, federation=True)
-
-    url = reverse("federation:music:files-list")
-    response = api_client.get(url)
-
-    assert response.status_code == 200
-    assert response.data["totalItems"] == 0
-
-
-def test_audio_file_list_actor_page_error(db, preferences, api_client, factories):
-    preferences["federation__music_needs_approval"] = False
-    url = reverse("federation:music:files-list")
-    response = api_client.get(url, data={"page": "nope"})
-
-    assert response.status_code == 400
-
-
-def test_audio_file_list_actor_page_error_too_far(
-    db, preferences, api_client, factories
-):
-    preferences["federation__music_needs_approval"] = False
-    url = reverse("federation:music:files-list")
-    response = api_client.get(url, data={"page": 5000})
-
-    assert response.status_code == 404
-
-
-def test_library_actor_includes_library_link(db, preferences, api_client):
-    url = reverse("federation:instance-actors-detail", kwargs={"actor": "library"})
-    response = api_client.get(url)
-    expected_links = [
-        {
-            "type": "Link",
-            "name": "library",
-            "mediaType": "application/activity+json",
-            "href": utils.full_url(reverse("federation:music:files-list")),
-        }
-    ]
-    assert response.status_code == 200
-    assert response.data["url"] == expected_links
-
-
-def test_can_fetch_library(superuser_api_client, mocker):
-    result = {"test": "test"}
-    scan = mocker.patch(
-        "funkwhale_api.federation.library.scan_from_account_name", return_value=result
-    )
-
-    url = reverse("api:v1:federation:libraries-fetch")
-    response = superuser_api_client.get(url, data={"account": "test@test.library"})
-
-    assert response.status_code == 200
-    assert response.data == result
-    scan.assert_called_once_with("test@test.library")
-
-
-def test_follow_library(superuser_api_client, mocker, factories, r_mock):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    actor = factories["federation.Actor"]()
-    follow = {"test": "follow"}
-    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
-    actor_data = serializers.ActorSerializer(actor).data
-    actor_data["url"] = [
-        {"href": "https://test.library", "name": "library", "type": "Link"}
-    ]
-    library_conf = {
-        "id": "https://test.library",
-        "items": range(10),
-        "actor": actor,
-        "page_size": 5,
-    }
-    library_data = serializers.PaginatedCollectionSerializer(library_conf).data
-    r_mock.get(actor.url, json=actor_data)
-    r_mock.get("https://test.library", json=library_data)
-    data = {
-        "actor": actor.url,
-        "autoimport": False,
-        "federation_enabled": True,
-        "download_files": False,
-    }
-
-    url = reverse("api:v1:federation:libraries-list")
-    response = superuser_api_client.post(url, data)
-
-    assert response.status_code == 201
-
-    follow = models.Follow.objects.get(actor=library_actor, target=actor, approved=None)
-    library = follow.library
-
-    assert response.data == serializers.APILibraryCreateSerializer(library).data
-
-    on_commit.assert_called_once_with(
-        activity.deliver,
-        serializers.FollowSerializer(follow).data,
-        on_behalf_of=library_actor,
-        to=[actor.url],
-    )
-
-
-def test_can_list_system_actor_following(factories, superuser_api_client):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    follow1 = factories["federation.Follow"](actor=library_actor)
-    factories["federation.Follow"]()
-
-    url = reverse("api:v1:federation:libraries-following")
-    response = superuser_api_client.get(url)
-
-    assert response.status_code == 200
-    assert response.data["results"] == [serializers.APIFollowSerializer(follow1).data]
-
-
-def test_can_list_system_actor_followers(factories, superuser_api_client):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    factories["federation.Follow"](actor=library_actor)
-    follow2 = factories["federation.Follow"](target=library_actor)
-
-    url = reverse("api:v1:federation:libraries-followers")
-    response = superuser_api_client.get(url)
-
-    assert response.status_code == 200
-    assert response.data["results"] == [serializers.APIFollowSerializer(follow2).data]
-
-
-def test_can_list_libraries(factories, superuser_api_client):
-    library1 = factories["federation.Library"]()
-    library2 = factories["federation.Library"]()
-
-    url = reverse("api:v1:federation:libraries-list")
-    response = superuser_api_client.get(url)
-
-    assert response.status_code == 200
-    assert response.data["results"] == [
-        serializers.APILibrarySerializer(library1).data,
-        serializers.APILibrarySerializer(library2).data,
-    ]
-
-
-def test_can_detail_library(factories, superuser_api_client):
-    library = factories["federation.Library"]()
-
+def test_local_actor_detail(factories, api_client):
+    user = factories["users.User"](with_actor=True)
     url = reverse(
-        "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)}
+        "federation:actors-detail",
+        kwargs={"preferred_username": user.actor.preferred_username},
     )
-    response = superuser_api_client.get(url)
+    serializer = serializers.ActorSerializer(user.actor)
+    response = api_client.get(url)
 
     assert response.status_code == 200
-    assert response.data == serializers.APILibrarySerializer(library).data
+    assert response.data == serializer.data
 
 
-def test_can_patch_library(factories, superuser_api_client):
-    library = factories["federation.Library"]()
-    data = {
-        "federation_enabled": not library.federation_enabled,
-        "download_files": not library.download_files,
-        "autoimport": not library.autoimport,
-    }
+def test_local_actor_inbox_post_requires_auth(factories, api_client):
+    user = factories["users.User"](with_actor=True)
     url = reverse(
-        "api:v1:federation:libraries-detail", kwargs={"uuid": str(library.uuid)}
+        "federation:actors-inbox",
+        kwargs={"preferred_username": user.actor.preferred_username},
     )
-    response = superuser_api_client.patch(url, data)
+    response = api_client.post(url, {"hello": "world"})
 
-    assert response.status_code == 200
-    library.refresh_from_db()
-
-    for k, v in data.items():
-        assert getattr(library, k) == v
+    assert response.status_code == 403
 
 
-def test_scan_library(factories, mocker, superuser_api_client):
-    scan = mocker.patch(
-        "funkwhale_api.federation.tasks.scan_library.delay",
-        return_value=mocker.Mock(id="id"),
-    )
-    library = factories["federation.Library"]()
-    now = timezone.now()
-    data = {"until": now}
+def test_local_actor_inbox_post(factories, api_client, mocker, authenticated_actor):
+    patched_receive = mocker.patch("funkwhale_api.federation.activity.receive")
+    user = factories["users.User"](with_actor=True)
     url = reverse(
-        "api:v1:federation:libraries-scan", kwargs={"uuid": str(library.uuid)}
+        "federation:actors-inbox",
+        kwargs={"preferred_username": user.actor.preferred_username},
     )
-    response = superuser_api_client.post(url, data)
+    response = api_client.post(url, {"hello": "world"}, format="json")
 
     assert response.status_code == 200
-    assert response.data == {"task": "id"}
-    scan.assert_called_once_with(library_id=library.pk, until=now)
-
-
-def test_list_library_tracks(factories, superuser_api_client):
-    library = factories["federation.Library"]()
-    lts = list(
-        reversed(
-            factories["federation.LibraryTrack"].create_batch(size=5, library=library)
-        )
+    patched_receive.assert_called_once_with(
+        activity={"hello": "world"},
+        on_behalf_of=authenticated_actor,
+        recipient=user.actor,
     )
-    factories["federation.LibraryTrack"].create_batch(size=5)
-    url = reverse("api:v1:federation:library-tracks-list")
-    response = superuser_api_client.get(url, {"library": library.uuid})
 
-    assert response.status_code == 200
-    assert response.data == {
-        "results": serializers.APILibraryTrackSerializer(lts, many=True).data,
-        "count": 5,
-        "previous": None,
-        "next": None,
-    }
 
-
-def test_can_update_follow_status(factories, superuser_api_client, mocker):
-    patched_accept = mocker.patch("funkwhale_api.federation.activity.accept_follow")
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    follow = factories["federation.Follow"](target=library_actor)
-
-    payload = {"follow": follow.pk, "approved": True}
-    url = reverse("api:v1:federation:libraries-followers")
-    response = superuser_api_client.patch(url, payload)
-    follow.refresh_from_db()
+def test_wellknown_webfinger_local(factories, api_client, settings, mocker):
+    user = factories["users.User"](with_actor=True)
+    url = reverse("federation:well-known-webfinger")
+    response = api_client.get(
+        url,
+        data={"resource": "acct:{}".format(user.actor.webfinger_subject)},
+        HTTP_ACCEPT="application/jrd+json",
+    )
+    serializer = serializers.ActorWebfingerSerializer(user.actor)
 
     assert response.status_code == 200
-    assert follow.approved is True
-    patched_accept.assert_called_once_with(follow)
+    assert response["Content-Type"] == "application/jrd+json"
+    assert response.data == serializer.data
 
 
-def test_can_filter_pending_follows(factories, superuser_api_client):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    factories["federation.Follow"](target=library_actor, approved=True)
+@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)
+    expected = serializers.LibrarySerializer(library).data
 
-    params = {"pending": True}
-    url = reverse("api:v1:federation:libraries-followers")
-    response = superuser_api_client.get(url, params)
+    url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
+    response = api_client.get(url)
 
     assert response.status_code == 200
-    assert len(response.data["results"]) == 0
+    assert response.data == expected
 
 
-def test_library_track_action_import(factories, superuser_api_client, mocker):
-    lt1 = factories["federation.LibraryTrack"]()
-    lt2 = factories["federation.LibraryTrack"](library=lt1.library)
-    lt3 = factories["federation.LibraryTrack"]()
-    factories["federation.LibraryTrack"](library=lt3.library)
-    mocked_run = mocker.patch("funkwhale_api.common.utils.on_commit")
+def test_music_library_retrieve_page_public(factories, api_client):
+    library = factories["music.Library"](privacy_level="everyone")
+    tf = factories["music.TrackFile"](library=library)
+    id = library.get_federation_id()
+    expected = serializers.CollectionPageSerializer(
+        {
+            "id": id,
+            "item_serializer": serializers.AudioSerializer,
+            "actor": library.actor,
+            "page": Paginator([tf], 1).page(1),
+            "name": library.name,
+            "summary": library.description,
+        }
+    ).data
 
-    payload = {
-        "objects": "all",
-        "action": "import",
-        "filters": {"library": lt1.library.uuid},
-    }
-    url = reverse("api:v1:federation:library-tracks-action")
-    response = superuser_api_client.post(url, payload, format="json")
-    batch = superuser_api_client.user.imports.latest("id")
-    expected = {"updated": 2, "action": "import", "result": {"batch": {"id": batch.pk}}}
+    url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
+    response = api_client.get(url, {"page": 1})
 
-    imported_lts = [lt1, lt2]
     assert response.status_code == 200
     assert response.data == expected
-    assert batch.jobs.count() == 2
-    for i, job in enumerate(batch.jobs.all()):
-        assert job.library_track == imported_lts[i]
-    mocked_run.assert_called_once_with(
-        music_tasks.import_batch_run.delay, import_batch_id=batch.pk
-    )
 
 
-def test_local_actor_detail(factories, api_client):
-    user = factories["users.User"](with_actor=True)
-    url = reverse(
-        "federation:actors-detail",
-        kwargs={"preferred_username": user.actor.preferred_username},
-    )
-    serializer = serializers.ActorSerializer(user.actor)
-    response = api_client.get(url)
+@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)
+    url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
+    response = api_client.get(url, {"page": 1})
 
-    assert response.status_code == 200
-    assert response.data == serializer.data
+    assert response.status_code == 403
 
 
-def test_wellknown_webfinger_local(factories, api_client, settings, mocker):
-    user = factories["users.User"](with_actor=True)
-    url = reverse("federation:well-known-webfinger")
-    response = api_client.get(
-        url,
-        data={"resource": "acct:{}".format(user.actor.webfinger_subject)},
-        HTTP_ACCEPT="application/jrd+json",
+@pytest.mark.parametrize("approved,expected", [(True, 200), (False, 403)])
+def test_music_library_retrieve_page_follow(
+    factories, api_client, authenticated_actor, approved, expected
+):
+    library = factories["music.Library"](privacy_level="me")
+    factories["federation.LibraryFollow"](
+        actor=authenticated_actor, target=library, approved=approved
     )
-    serializer = serializers.ActorWebfingerSerializer(user.actor)
+    url = reverse("federation:music:libraries-detail", kwargs={"uuid": library.uuid})
+    response = api_client.get(url, {"page": 1})
 
-    assert response.status_code == 200
-    assert response["Content-Type"] == "application/jrd+json"
-    assert response.data == serializer.data
+    assert response.status_code == expected
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index baf816fc860ba7d2dbc3b1f6dbdfdc8d2187b541..f8a782ebb430eb0951ee94bdd9dfcac5305fe176 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -17,6 +17,7 @@ def test_permissions(assert_user_permission, view, permissions, operator):
     assert_user_permission(view, permissions, operator)
 
 
+@pytest.mark.skip(reason="Refactoring in progress")
 def test_track_file_view(factories, superuser_api_client):
     tfs = factories["music.TrackFile"].create_batch(size=5)
     qs = tfs[0].__class__.objects.order_by("-creation_date")
diff --git a/api/tests/music/test_activity.py b/api/tests/music/test_activity.py
index 8c119394d620b28eac7a594a89bae552e968c3ad..d0da65b92b17aa0234eaa59ad6b6385420750e1e 100644
--- a/api/tests/music/test_activity.py
+++ b/api/tests/music/test_activity.py
@@ -1,3 +1,7 @@
+from funkwhale_api.music import serializers
+from funkwhale_api.music import signals
+
+
 def test_get_track_activity_url_mbid(factories):
     track = factories["music.Track"]()
     expected = "https://musicbrainz.org/recording/{}".format(track.mbid)
@@ -8,3 +12,27 @@ def test_get_track_activity_url_no_mbid(settings, factories):
     track = factories["music.Track"](mbid=None)
     expected = settings.FUNKWHALE_URL + "/tracks/{}".format(track.pk)
     assert track.get_activity_url() == expected
+
+
+def test_track_file_import_status_updated_broadcast(factories, mocker):
+    group_send = mocker.patch("funkwhale_api.common.channels.group_send")
+    user = factories["users.User"]()
+    tf = factories["music.TrackFile"](
+        import_status="finished", library__actor__user=user
+    )
+    signals.track_file_import_status_updated.send(
+        sender=None, track_file=tf, old_status="pending", new_status="finished"
+    )
+    group_send.assert_called_once_with(
+        "user.{}.imports".format(user.pk),
+        {
+            "type": "event.send",
+            "text": "",
+            "data": {
+                "type": "import.status_updated",
+                "old_status": "pending",
+                "new_status": "finished",
+                "track_file": serializers.TrackFileForOwnerSerializer(tf).data,
+            },
+        },
+    )
diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py
index 29a712ce6218db293947e98897dc287710b22d58..63bdcc28e1e49d136d970f0cfe185d60e6b14ee4 100644
--- a/api/tests/music/test_api.py
+++ b/api/tests/music/test_api.py
@@ -1,217 +1,12 @@
-import json
 import os
 
 import pytest
 from django.urls import reverse
 
-from funkwhale_api.music import models, tasks
 
 DATA_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
-def test_can_submit_youtube_url_for_track_import(
-    settings, artists, albums, tracks, mocker, superuser_client
-):
-    mocker.patch("funkwhale_api.music.tasks.import_job_run.delay")
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.artists.get",
-        return_value=artists["get"]["adhesive_wombat"],
-    )
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.releases.get",
-        return_value=albums["get"]["marsupial"],
-    )
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.recordings.get",
-        return_value=tracks["get"]["8bitadventures"],
-    )
-    mocker.patch(
-        "funkwhale_api.music.models.TrackFile.download_file", return_value=None
-    )
-    mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
-    video_id = "tPEE9ZwTmy0"
-    url = reverse("api:v1:submit-single")
-    video_url = "https://www.youtube.com/watch?v={0}".format(video_id)
-    response = superuser_client.post(url, {"import_url": video_url, "mbid": mbid})
-
-    assert response.status_code == 201
-    batch = superuser_client.user.imports.latest("id")
-    job = batch.jobs.latest("id")
-    assert job.status == "pending"
-    assert str(job.mbid) == mbid
-    assert job.source == video_url
-
-
-def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
-    mocker.patch("funkwhale_api.music.tasks.import_job_run")
-    mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
-    video_id = "tPEE9ZwTmy0"
-    url = reverse("api:v1:submit-single")
-    superuser_client.post(
-        url,
-        {
-            "import_url": "https://www.youtube.com/watch?v={0}".format(video_id),
-            "mbid": mbid,
-        },
-    )
-
-    batch = models.ImportBatch.objects.latest("id")
-    assert batch.jobs.count() == 1
-    assert batch.submitted_by == superuser_client.user
-    assert batch.status == "pending"
-    job = batch.jobs.first()
-    assert str(job.mbid) == mbid
-    assert job.status == "pending"
-    assert job.source == "https://www.youtube.com/watch?v={0}".format(video_id)
-
-
-def test_can_import_whole_album(artists, albums, mocker, superuser_client):
-    mocker.patch("funkwhale_api.music.tasks.import_job_run")
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.artists.get", return_value=artists["get"]["soad"]
-    )
-    mocker.patch("funkwhale_api.musicbrainz.api.images.get_front", return_value=b"")
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.releases.get",
-        return_value=albums["get_with_includes"]["hypnotize"],
-    )
-    payload = {
-        "releaseId": "47ae093f-1607-49a3-be11-a15d335ccc94",
-        "tracks": [
-            {
-                "mbid": "1968a9d6-8d92-4051-8f76-674e157b6eed",
-                "source": "https://www.youtube.com/watch?v=1111111111",
-            },
-            {
-                "mbid": "2968a9d6-8d92-4051-8f76-674e157b6eed",
-                "source": "https://www.youtube.com/watch?v=2222222222",
-            },
-            {
-                "mbid": "3968a9d6-8d92-4051-8f76-674e157b6eed",
-                "source": "https://www.youtube.com/watch?v=3333333333",
-            },
-        ],
-    }
-    url = reverse("api:v1:submit-album")
-    superuser_client.post(url, json.dumps(payload), content_type="application/json")
-
-    batch = models.ImportBatch.objects.latest("id")
-    assert batch.jobs.count() == 3
-    assert batch.submitted_by == superuser_client.user
-    assert batch.status == "pending"
-
-    album = models.Album.objects.latest("id")
-    assert str(album.mbid) == "47ae093f-1607-49a3-be11-a15d335ccc94"
-    medium_data = albums["get_with_includes"]["hypnotize"]["release"]["medium-list"][0]
-    assert int(medium_data["track-count"]) == album.tracks.all().count()
-
-    for track in medium_data["track-list"]:
-        instance = models.Track.objects.get(mbid=track["recording"]["id"])
-        assert instance.title == track["recording"]["title"]
-        assert instance.position == int(track["position"])
-        assert instance.title == track["recording"]["title"]
-
-    for row in payload["tracks"]:
-        job = models.ImportJob.objects.get(mbid=row["mbid"])
-        assert str(job.mbid) == row["mbid"]
-        assert job.status == "pending"
-        assert job.source == row["source"]
-
-
-def test_can_import_whole_artist(artists, albums, mocker, superuser_client):
-    mocker.patch("funkwhale_api.music.tasks.import_job_run")
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.artists.get", return_value=artists["get"]["soad"]
-    )
-    mocker.patch("funkwhale_api.musicbrainz.api.images.get_front", return_value=b"")
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.releases.get",
-        return_value=albums["get_with_includes"]["hypnotize"],
-    )
-    payload = {
-        "artistId": "mbid",
-        "albums": [
-            {
-                "releaseId": "47ae093f-1607-49a3-be11-a15d335ccc94",
-                "tracks": [
-                    {
-                        "mbid": "1968a9d6-8d92-4051-8f76-674e157b6eed",
-                        "source": "https://www.youtube.com/watch?v=1111111111",
-                    },
-                    {
-                        "mbid": "2968a9d6-8d92-4051-8f76-674e157b6eed",
-                        "source": "https://www.youtube.com/watch?v=2222222222",
-                    },
-                    {
-                        "mbid": "3968a9d6-8d92-4051-8f76-674e157b6eed",
-                        "source": "https://www.youtube.com/watch?v=3333333333",
-                    },
-                ],
-            }
-        ],
-    }
-    url = reverse("api:v1:submit-artist")
-    superuser_client.post(url, json.dumps(payload), content_type="application/json")
-
-    batch = models.ImportBatch.objects.latest("id")
-    assert batch.jobs.count() == 3
-    assert batch.submitted_by == superuser_client.user
-    assert batch.status == "pending"
-
-    album = models.Album.objects.latest("id")
-    assert str(album.mbid) == "47ae093f-1607-49a3-be11-a15d335ccc94"
-    medium_data = albums["get_with_includes"]["hypnotize"]["release"]["medium-list"][0]
-    assert int(medium_data["track-count"]) == album.tracks.all().count()
-
-    for track in medium_data["track-list"]:
-        instance = models.Track.objects.get(mbid=track["recording"]["id"])
-        assert instance.title == track["recording"]["title"]
-        assert instance.position == int(track["position"])
-        assert instance.title == track["recording"]["title"]
-
-    for row in payload["albums"][0]["tracks"]:
-        job = models.ImportJob.objects.get(mbid=row["mbid"])
-        assert str(job.mbid) == row["mbid"]
-        assert job.status == "pending"
-        assert job.source == row["source"]
-
-
-def test_user_can_create_an_empty_batch(superuser_api_client, factories):
-    url = reverse("api:v1:import-batches-list")
-    response = superuser_api_client.post(url)
-
-    assert response.status_code == 201
-
-    batch = superuser_api_client.user.imports.latest("id")
-
-    assert batch.submitted_by == superuser_api_client.user
-    assert batch.source == "api"
-
-
-def test_user_can_create_import_job_with_file(superuser_api_client, factories, mocker):
-    path = os.path.join(DATA_DIR, "test.ogg")
-    m = mocker.patch("funkwhale_api.common.utils.on_commit")
-    batch = factories["music.ImportBatch"](submitted_by=superuser_api_client.user)
-    url = reverse("api:v1:import-jobs-list")
-    with open(path, "rb") as f:
-        content = f.read()
-        f.seek(0)
-        response = superuser_api_client.post(
-            url, {"batch": batch.pk, "audio_file": f, "source": "file://"}
-        )
-
-    assert response.status_code == 201
-
-    job = batch.jobs.latest("id")
-
-    assert job.status == "pending"
-    assert job.source.startswith("file://")
-    assert "test.ogg" in job.source
-    assert job.audio_file.read() == content
-
-    m.assert_called_once_with(tasks.import_job_run.delay, import_job_id=job.pk)
-
-
 @pytest.mark.parametrize(
     "route,method",
     [
@@ -234,9 +29,9 @@ def test_track_file_url_is_restricted_to_authenticated_users(
     api_client, factories, preferences
 ):
     preferences["common__api_authentication_required"] = True
-    f = factories["music.TrackFile"]()
-    assert f.audio_file is not None
-    url = f.path
+    tf = factories["music.TrackFile"](library__privacy_level="instance")
+    assert tf.audio_file is not None
+    url = tf.track.listen_url
     response = api_client.get(url)
     assert response.status_code == 401
 
@@ -244,11 +39,12 @@ def test_track_file_url_is_restricted_to_authenticated_users(
 def test_track_file_url_is_accessible_to_authenticated_users(
     logged_in_api_client, factories, preferences
 ):
+    actor = logged_in_api_client.user.create_actor()
     preferences["common__api_authentication_required"] = True
-    f = factories["music.TrackFile"]()
-    assert f.audio_file is not None
-    url = f.path
+    tf = factories["music.TrackFile"](library__actor=actor)
+    assert tf.audio_file is not None
+    url = tf.track.listen_url
     response = logged_in_api_client.get(url)
 
     assert response.status_code == 200
-    assert response["X-Accel-Redirect"] == "/_protected{}".format(f.audio_file.url)
+    assert response["X-Accel-Redirect"] == "/_protected{}".format(tf.audio_file.url)
diff --git a/api/tests/music/test_commands.py b/api/tests/music/test_commands.py
index 03a9420dc77e598231fe0ac9056bea67868a3897..0ec4c58d259bf70293738ce54f55101f83204d4c 100644
--- a/api/tests/music/test_commands.py
+++ b/api/tests/music/test_commands.py
@@ -30,8 +30,10 @@ def test_fix_track_files_bitrate_length(factories, mocker):
 
 
 def test_fix_track_files_size(factories, mocker):
-    tf1 = factories["music.TrackFile"](size=1)
-    tf2 = factories["music.TrackFile"](size=None)
+    tf1 = factories["music.TrackFile"]()
+    tf2 = factories["music.TrackFile"]()
+    tf1.__class__.objects.filter(pk=tf1.pk).update(size=1)
+    tf2.__class__.objects.filter(pk=tf2.pk).update(size=None)
     c = fix_track_files.Command()
 
     mocker.patch("funkwhale_api.music.models.TrackFile.get_file_size", return_value=2)
diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py
index 12185f88805db6d5191e39f1c9bb510a91c91d32..1c1e5fea567202fc9e662a933954c1d348d55fc1 100644
--- a/api/tests/music/test_import.py
+++ b/api/tests/music/test_import.py
@@ -1,249 +1,15 @@
-import json
 import os
 import pytest
 import uuid
 
 from django import forms
-from django.urls import reverse
 
-from funkwhale_api.federation import actors
-from funkwhale_api.federation import serializers as federation_serializers
 from funkwhale_api.music import importers
 from funkwhale_api.music import models
-from funkwhale_api.music import tasks
 
 DATA_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
-def test_create_import_can_bind_to_request(
-    artists, albums, mocker, factories, superuser_api_client
-):
-    request = factories["requests.ImportRequest"]()
-
-    mocker.patch("funkwhale_api.music.tasks.import_job_run")
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.artists.get", return_value=artists["get"]["soad"]
-    )
-    mocker.patch("funkwhale_api.musicbrainz.api.images.get_front", return_value=b"")
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.releases.get",
-        return_value=albums["get_with_includes"]["hypnotize"],
-    )
-    payload = {
-        "releaseId": "47ae093f-1607-49a3-be11-a15d335ccc94",
-        "importRequest": request.pk,
-        "tracks": [
-            {
-                "mbid": "1968a9d6-8d92-4051-8f76-674e157b6eed",
-                "source": "https://www.youtube.com/watch?v=1111111111",
-            }
-        ],
-    }
-    url = reverse("api:v1:submit-album")
-    superuser_api_client.post(url, json.dumps(payload), content_type="application/json")
-    batch = request.import_batches.latest("id")
-
-    assert batch.import_request == request
-
-
-def test_import_job_from_federation_no_musicbrainz(factories, mocker):
-    mocker.patch(
-        "funkwhale_api.music.utils.get_audio_file_data",
-        return_value={"bitrate": 24, "length": 666},
-    )
-    mocker.patch("funkwhale_api.music.models.TrackFile.get_file_size", return_value=42)
-    lt = factories["federation.LibraryTrack"](
-        artist_name="Hello",
-        album_title="World",
-        title="Ping",
-        metadata__length=42,
-        metadata__bitrate=43,
-        metadata__size=44,
-    )
-    job = factories["music.ImportJob"](federation=True, library_track=lt)
-
-    tasks.import_job_run(import_job_id=job.pk)
-    job.refresh_from_db()
-
-    tf = job.track_file
-    assert tf.mimetype == lt.audio_mimetype
-    assert tf.duration == 42
-    assert tf.bitrate == 43
-    assert tf.size == 44
-    assert tf.library_track == job.library_track
-    assert tf.track.title == "Ping"
-    assert tf.track.artist.name == "Hello"
-    assert tf.track.album.title == "World"
-
-
-def test_import_job_from_federation_musicbrainz_recording(factories, mocker):
-    t = factories["music.Track"]()
-    track_from_api = mocker.patch(
-        "funkwhale_api.music.models.Track.get_or_create_from_api",
-        return_value=(t, True),
-    )
-    lt = factories["federation.LibraryTrack"](
-        metadata__recording__musicbrainz=True, artist_name="Hello", album_title="World"
-    )
-    job = factories["music.ImportJob"](federation=True, library_track=lt)
-
-    tasks.import_job_run(import_job_id=job.pk)
-    job.refresh_from_db()
-
-    tf = job.track_file
-    assert tf.mimetype == lt.audio_mimetype
-    assert tf.library_track == job.library_track
-    assert tf.track == t
-    track_from_api.assert_called_once_with(
-        mbid=lt.metadata["recording"]["musicbrainz_id"]
-    )
-
-
-def test_import_job_from_federation_musicbrainz_release(factories, mocker):
-    a = factories["music.Album"]()
-    album_from_api = mocker.patch(
-        "funkwhale_api.music.models.Album.get_or_create_from_api",
-        return_value=(a, True),
-    )
-    lt = factories["federation.LibraryTrack"](
-        metadata__release__musicbrainz=True, artist_name="Hello", title="Ping"
-    )
-    job = factories["music.ImportJob"](federation=True, library_track=lt)
-
-    tasks.import_job_run(import_job_id=job.pk)
-    job.refresh_from_db()
-
-    tf = job.track_file
-    assert tf.mimetype == lt.audio_mimetype
-    assert tf.library_track == job.library_track
-    assert tf.track.title == "Ping"
-    assert tf.track.artist == a.artist
-    assert tf.track.album == a
-
-    album_from_api.assert_called_once_with(
-        mbid=lt.metadata["release"]["musicbrainz_id"]
-    )
-
-
-def test_import_job_from_federation_musicbrainz_artist(factories, mocker):
-    a = factories["music.Artist"]()
-    artist_from_api = mocker.patch(
-        "funkwhale_api.music.models.Artist.get_or_create_from_api",
-        return_value=(a, True),
-    )
-    lt = factories["federation.LibraryTrack"](
-        metadata__artist__musicbrainz=True, album_title="World", title="Ping"
-    )
-    job = factories["music.ImportJob"](federation=True, library_track=lt)
-
-    tasks.import_job_run(import_job_id=job.pk)
-    job.refresh_from_db()
-
-    tf = job.track_file
-    assert tf.mimetype == lt.audio_mimetype
-    assert tf.library_track == job.library_track
-
-    assert tf.track.title == "Ping"
-    assert tf.track.artist == a
-    assert tf.track.album.artist == a
-    assert tf.track.album.title == "World"
-
-    artist_from_api.assert_called_once_with(
-        mbid=lt.metadata["artist"]["musicbrainz_id"]
-    )
-
-
-def test_import_job_run_triggers_notifies_followers(factories, mocker, tmpfile):
-    mocker.patch(
-        "funkwhale_api.downloader.download",
-        return_value={"audio_file_path": tmpfile.name},
-    )
-    mocked_notify = mocker.patch(
-        "funkwhale_api.music.tasks.import_batch_notify_followers.delay"
-    )
-    batch = factories["music.ImportBatch"]()
-    job = factories["music.ImportJob"](finished=True, batch=batch)
-    factories["music.Track"](mbid=job.mbid)
-
-    batch.update_status()
-    batch.refresh_from_db()
-
-    assert batch.status == "finished"
-
-    mocked_notify.assert_called_once_with(import_batch_id=batch.pk)
-
-
-def test_import_batch_notifies_followers_skip_on_disabled_federation(
-    preferences, factories, mocker
-):
-    mocked_deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
-    batch = factories["music.ImportBatch"](finished=True)
-    preferences["federation__enabled"] = False
-    tasks.import_batch_notify_followers(import_batch_id=batch.pk)
-
-    mocked_deliver.assert_not_called()
-
-
-def test_import_batch_notifies_followers_skip_on_federation_import(factories, mocker):
-    mocked_deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
-    batch = factories["music.ImportBatch"](finished=True, federation=True)
-    tasks.import_batch_notify_followers(import_batch_id=batch.pk)
-
-    mocked_deliver.assert_not_called()
-
-
-def test_import_batch_notifies_followers(factories, mocker):
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-
-    f1 = factories["federation.Follow"](approved=True, target=library_actor)
-    factories["federation.Follow"](approved=False, target=library_actor)
-    factories["federation.Follow"]()
-
-    mocked_deliver = mocker.patch("funkwhale_api.federation.activity.deliver")
-    batch = factories["music.ImportBatch"]()
-    job1 = factories["music.ImportJob"](finished=True, batch=batch)
-    factories["music.ImportJob"](finished=True, federation=True, batch=batch)
-    factories["music.ImportJob"](status="pending", batch=batch)
-
-    batch.status = "finished"
-    batch.save()
-    tasks.import_batch_notify_followers(import_batch_id=batch.pk)
-
-    # only f1 match the requirements to be notified
-    # and only job1 is a non federated track with finished import
-    expected = {
-        "@context": federation_serializers.AP_CONTEXT,
-        "actor": library_actor.url,
-        "type": "Create",
-        "id": batch.get_federation_url(),
-        "to": [f1.actor.url],
-        "object": federation_serializers.CollectionSerializer(
-            {
-                "id": batch.get_federation_url(),
-                "items": [job1.track_file],
-                "actor": library_actor,
-                "item_serializer": federation_serializers.AudioSerializer,
-            }
-        ).data,
-    }
-
-    mocked_deliver.assert_called_once_with(
-        expected, on_behalf_of=library_actor, to=[f1.actor.url]
-    )
-
-
-def test__do_import_in_place_mbid(factories, tmpfile):
-    path = os.path.join(DATA_DIR, "test.ogg")
-    job = factories["music.ImportJob"](in_place=True, source="file://{}".format(path))
-
-    factories["music.Track"](mbid=job.mbid)
-    tf = tasks._do_import(job, use_acoustid=False)
-
-    assert bool(tf.audio_file) is False
-    assert tf.source == "file://{}".format(path)
-    assert tf.mimetype == "audio/ogg"
-
-
 def test_importer_cleans():
     importer = importers.Importer(models.Artist)
     with pytest.raises(forms.ValidationError):
diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py
index 1bd4282fe36b9bde03094a8e6304283ea97e9b59..e4fc6d9cf6d77fea390d3f3ed2affcbe4b783e49 100644
--- a/api/tests/music/test_models.py
+++ b/api/tests/music/test_models.py
@@ -2,6 +2,8 @@ import os
 
 import pytest
 
+from django.utils import timezone
+
 from funkwhale_api.music import importers, models, tasks
 
 DATA_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -148,29 +150,6 @@ def test_import_track_with_different_artist_than_release(factories, mocker):
     assert track.artist == artist
 
 
-def test_import_job_is_bound_to_track_file(factories, mocker):
-    track = factories["music.Track"]()
-    job = factories["music.ImportJob"](mbid=track.mbid)
-
-    mocker.patch("funkwhale_api.music.models.TrackFile.download_file")
-    tasks.import_job_run(import_job_id=job.pk)
-    job.refresh_from_db()
-    assert job.track_file.track == track
-
-
-@pytest.mark.parametrize("status", ["pending", "errored", "finished"])
-def test_saving_job_updates_batch_status(status, factories, mocker):
-    batch = factories["music.ImportBatch"]()
-
-    assert batch.status == "pending"
-
-    factories["music.ImportJob"](batch=batch, status=status)
-
-    batch.refresh_from_db()
-
-    assert batch.status == status
-
-
 @pytest.mark.parametrize(
     "extention,mimetype", [("ogg", "audio/ogg"), ("mp3", "audio/mpeg")]
 )
@@ -178,7 +157,7 @@ def test_audio_track_mime_type(extention, mimetype, factories):
 
     name = ".".join(["test", extention])
     path = os.path.join(DATA_DIR, name)
-    tf = factories["music.TrackFile"](audio_file__from_path=path)
+    tf = factories["music.TrackFile"](audio_file__from_path=path, mimetype=None)
 
     assert tf.mimetype == mimetype
 
@@ -199,14 +178,6 @@ def test_track_get_file_size(factories):
     assert tf.get_file_size() == 297745
 
 
-def test_track_get_file_size_federation(factories):
-    tf = factories["music.TrackFile"](
-        federation=True, library_track__with_audio_file=True
-    )
-
-    assert tf.get_file_size() == tf.library_track.audio_file.size
-
-
 def test_track_get_file_size_in_place(factories):
     name = "test.mp3"
     path = os.path.join(DATA_DIR, name)
@@ -221,3 +192,230 @@ def test_album_get_image_content(factories):
     album.refresh_from_db()
 
     assert album.cover.read() == b"test"
+
+
+def test_library(factories):
+    now = timezone.now()
+    actor = factories["federation.Actor"]()
+    library = factories["music.Library"](
+        name="Hello world", description="hello", actor=actor, privacy_level="instance"
+    )
+
+    assert library.creation_date >= now
+    assert library.files.count() == 0
+    assert library.uuid is not None
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
+)
+def test_playable_by_correct_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    queryset = tf.library.files.playable_by(tf.library.actor)
+    match = tf in list(queryset)
+    assert match is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
+)
+def test_playable_by_instance_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain)
+    queryset = tf.library.files.playable_by(instance_actor)
+    match = tf in list(queryset)
+    assert match is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
+)
+def test_playable_by_anonymous(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    queryset = tf.library.files.playable_by(None)
+    match = tf in list(queryset)
+    assert match is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
+)
+def test_track_playable_by_correct_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"]()
+    queryset = models.Track.objects.playable_by(
+        tf.library.actor
+    ).annotate_playable_by_actor(tf.library.actor)
+    match = tf.track in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
+)
+def test_track_playable_by_instance_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain)
+    queryset = models.Track.objects.playable_by(
+        instance_actor
+    ).annotate_playable_by_actor(instance_actor)
+    match = tf.track in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
+)
+def test_track_playable_by_anonymous(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    queryset = models.Track.objects.playable_by(None).annotate_playable_by_actor(None)
+    match = tf.track in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
+)
+def test_album_playable_by_correct_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"]()
+
+    queryset = models.Album.objects.playable_by(
+        tf.library.actor
+    ).annotate_playable_by_actor(tf.library.actor)
+    match = tf.track.album in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
+)
+def test_album_playable_by_instance_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain)
+    queryset = models.Album.objects.playable_by(
+        instance_actor
+    ).annotate_playable_by_actor(instance_actor)
+    match = tf.track.album in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
+)
+def test_album_playable_by_anonymous(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    queryset = models.Album.objects.playable_by(None).annotate_playable_by_actor(None)
+    match = tf.track.album in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", True), ("instance", True), ("everyone", True)]
+)
+def test_artist_playable_by_correct_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"]()
+
+    queryset = models.Artist.objects.playable_by(
+        tf.library.actor
+    ).annotate_playable_by_actor(tf.library.actor)
+    match = tf.track.artist in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", True), ("everyone", True)]
+)
+def test_artist_playable_by_instance_actor(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    instance_actor = factories["federation.Actor"](domain=tf.library.actor.domain)
+    queryset = models.Artist.objects.playable_by(
+        instance_actor
+    ).annotate_playable_by_actor(instance_actor)
+    match = tf.track.artist in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+@pytest.mark.parametrize(
+    "privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
+)
+def test_artist_playable_by_anonymous(privacy_level, expected, factories):
+    tf = factories["music.TrackFile"](library__privacy_level=privacy_level)
+    queryset = models.Artist.objects.playable_by(None).annotate_playable_by_actor(None)
+    match = tf.track.artist in list(queryset)
+    assert match is expected
+    if expected:
+        assert bool(queryset.first().is_playable_by_actor) is expected
+
+
+def test_track_file_listen_url(factories):
+    tf = factories["music.TrackFile"]()
+    expected = tf.track.listen_url + "?file={}".format(tf.uuid)
+
+    assert tf.listen_url == expected
+
+
+def test_library_schedule_scan(factories, now, mocker):
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+    library = factories["music.Library"](files_count=5)
+
+    scan = library.schedule_scan()
+
+    assert scan.creation_date >= now
+    assert scan.status == "pending"
+    assert scan.library == library
+    assert scan.total_files == 5
+    assert scan.processed_files == 0
+    assert scan.errored_files == 0
+    assert scan.modification_date is None
+
+    on_commit.assert_called_once_with(
+        tasks.start_library_scan.delay, library_scan_id=scan.pk
+    )
+
+
+def test_library_schedule_scan_too_recent(factories, now):
+    scan = factories["music.LibraryScan"]()
+    result = scan.library.schedule_scan()
+
+    assert result is None
+    assert scan.library.scans.count() == 1
+
+
+def test_get_audio_data(factories):
+    tf = factories["music.TrackFile"]()
+
+    result = tf.get_audio_data()
+
+    assert result == {"duration": 229, "bitrate": 128000, "size": 3459481}
+
+
+@pytest.mark.skip(reason="Refactoring in progress")
+def test_library_viewable_by():
+    assert False
+
+
+def test_library_queryset_with_follows(factories):
+    library1 = factories["music.Library"]()
+    library2 = factories["music.Library"]()
+    follow = factories["federation.LibraryFollow"](target=library2)
+    qs = library1.__class__.objects.with_follows(follow.actor).order_by("pk")
+
+    l1 = list(qs)[0]
+    l2 = list(qs)[1]
+    assert l1._follows == []
+    assert l2._follows == [follow]
diff --git a/api/tests/music/test_permissions.py b/api/tests/music/test_permissions.py
deleted file mode 100644
index 5f73a361e05bef73a51685f351a4d6a4fcb264d9..0000000000000000000000000000000000000000
--- a/api/tests/music/test_permissions.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from rest_framework.views import APIView
-
-from funkwhale_api.federation import actors
-from funkwhale_api.music import permissions
-
-
-def test_list_permission_no_protect(preferences, anonymous_user, api_request):
-    preferences["common__api_authentication_required"] = False
-    view = APIView.as_view()
-    permission = permissions.Listen()
-    request = api_request.get("/")
-    assert permission.has_permission(request, view) is True
-
-
-def test_list_permission_protect_authenticated(factories, api_request, preferences):
-    preferences["common__api_authentication_required"] = True
-    user = factories["users.User"]()
-    view = APIView.as_view()
-    permission = permissions.Listen()
-    request = api_request.get("/")
-    setattr(request, "user", user)
-    assert permission.has_permission(request, view) is True
-
-
-def test_list_permission_protect_not_following_actor(
-    factories, api_request, preferences
-):
-    preferences["common__api_authentication_required"] = True
-    actor = factories["federation.Actor"]()
-    view = APIView.as_view()
-    permission = permissions.Listen()
-    request = api_request.get("/")
-    setattr(request, "actor", actor)
-    assert permission.has_permission(request, view) is False
-
-
-def test_list_permission_protect_following_actor(factories, api_request, preferences):
-    preferences["common__api_authentication_required"] = True
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    follow = factories["federation.Follow"](approved=True, target=library_actor)
-    view = APIView.as_view()
-    permission = permissions.Listen()
-    request = api_request.get("/")
-    setattr(request, "actor", follow.actor)
-
-    assert permission.has_permission(request, view) is True
-
-
-def test_list_permission_protect_following_actor_not_approved(
-    factories, api_request, preferences
-):
-    preferences["common__api_authentication_required"] = True
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
-    follow = factories["federation.Follow"](approved=False, target=library_actor)
-    view = APIView.as_view()
-    permission = permissions.Listen()
-    request = api_request.get("/")
-    setattr(request, "actor", follow.actor)
-
-    assert permission.has_permission(request, view) is False
diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py
index 8705354f7b2090bb756690c7d9283c96e6e33da7..e9b00fe4e49822ba80d2caa643e03669549dbda8 100644
--- a/api/tests/music/test_serializers.py
+++ b/api/tests/music/test_serializers.py
@@ -1,4 +1,6 @@
+from funkwhale_api.music import models
 from funkwhale_api.music import serializers
+from funkwhale_api.music import tasks
 
 
 def test_artist_album_serializer(factories, to_api_date):
@@ -12,6 +14,7 @@ def test_artist_album_serializer(factories, to_api_date):
         "artist": album.artist.id,
         "creation_date": to_api_date(album.creation_date),
         "tracks_count": 1,
+        "is_playable": None,
         "cover": {
             "original": album.cover.url,
             "square_crop": album.cover.crop["400x400"].url,
@@ -53,8 +56,9 @@ def test_album_track_serializer(factories, to_api_date):
         "mbid": str(track.mbid),
         "title": track.title,
         "position": track.position,
+        "is_playable": None,
         "creation_date": to_api_date(track.creation_date),
-        "files": [serializers.TrackFileSerializer(tf).data],
+        "listen_url": track.listen_url,
     }
     serializer = serializers.AlbumTrackSerializer(track)
     assert serializer.data == expected
@@ -64,20 +68,54 @@ def test_track_file_serializer(factories, to_api_date):
     tf = factories["music.TrackFile"]()
 
     expected = {
-        "id": tf.id,
-        "path": tf.path,
-        "source": tf.source,
+        "uuid": str(tf.uuid),
         "filename": tf.filename,
-        "track": tf.track.pk,
+        "track": serializers.TrackSerializer(tf.track).data,
         "duration": tf.duration,
         "mimetype": tf.mimetype,
         "bitrate": tf.bitrate,
         "size": tf.size,
+        "library": serializers.LibraryForOwnerSerializer(tf.library).data,
+        "creation_date": tf.creation_date.isoformat().split("+")[0] + "Z",
+        "import_date": None,
+        "import_status": "pending",
     }
     serializer = serializers.TrackFileSerializer(tf)
     assert serializer.data == expected
 
 
+def test_track_file_owner_serializer(factories, to_api_date):
+    tf = factories["music.TrackFile"](
+        import_status="success",
+        import_details={"hello": "world"},
+        import_metadata={"import": "metadata"},
+        import_reference="ref",
+        metadata={"test": "metadata"},
+        source="upload://test",
+    )
+
+    expected = {
+        "uuid": str(tf.uuid),
+        "filename": tf.filename,
+        "track": serializers.TrackSerializer(tf.track).data,
+        "duration": tf.duration,
+        "mimetype": tf.mimetype,
+        "bitrate": tf.bitrate,
+        "size": tf.size,
+        "library": serializers.LibraryForOwnerSerializer(tf.library).data,
+        "creation_date": tf.creation_date.isoformat().split("+")[0] + "Z",
+        "metadata": {"test": "metadata"},
+        "import_metadata": {"import": "metadata"},
+        "import_date": None,
+        "import_status": "success",
+        "import_details": {"hello": "world"},
+        "source": "upload://test",
+        "import_reference": "ref",
+    }
+    serializer = serializers.TrackFileForOwnerSerializer(tf)
+    assert serializer.data == expected
+
+
 def test_album_serializer(factories, to_api_date):
     track1 = factories["music.Track"](position=2)
     track2 = factories["music.Track"](position=1, album=track1.album)
@@ -88,6 +126,7 @@ def test_album_serializer(factories, to_api_date):
         "title": album.title,
         "artist": serializers.ArtistSimpleSerializer(album.artist).data,
         "creation_date": to_api_date(album.creation_date),
+        "is_playable": None,
         "cover": {
             "original": album.cover.url,
             "square_crop": album.cover.crop["400x400"].url,
@@ -113,9 +152,94 @@ def test_track_serializer(factories, to_api_date):
         "mbid": str(track.mbid),
         "title": track.title,
         "position": track.position,
+        "is_playable": None,
         "creation_date": to_api_date(track.creation_date),
         "lyrics": track.get_lyrics_url(),
-        "files": [serializers.TrackFileSerializer(tf).data],
+        "listen_url": track.listen_url,
     }
     serializer = serializers.TrackSerializer(track)
     assert serializer.data == expected
+
+
+def test_user_cannot_bind_file_to_a_not_owned_library(factories):
+    user = factories["users.User"]()
+    library = factories["music.Library"]()
+
+    s = serializers.TrackFileForOwnerSerializer(
+        data={"library": library.uuid, "source": "upload://test"},
+        context={"user": user},
+    )
+    assert s.is_valid() is False
+    assert "library" in s.errors
+
+
+def test_user_can_create_file_in_own_library(factories, uploaded_audio_file):
+    user = factories["users.User"]()
+    library = factories["music.Library"](actor__user=user)
+    s = serializers.TrackFileForOwnerSerializer(
+        data={
+            "library": library.uuid,
+            "source": "upload://test",
+            "audio_file": uploaded_audio_file,
+        },
+        context={"user": user},
+    )
+    assert s.is_valid(raise_exception=True) is True
+    tf = s.save()
+
+    assert tf.library == library
+
+
+def test_create_file_checks_for_user_quota(
+    factories, preferences, uploaded_audio_file, mocker
+):
+    mocker.patch(
+        "funkwhale_api.users.models.User.get_quota_status",
+        return_value={"remaining": 0},
+    )
+    user = factories["users.User"]()
+    library = factories["music.Library"](actor__user=user)
+    s = serializers.TrackFileForOwnerSerializer(
+        data={
+            "library": library.uuid,
+            "source": "upload://test",
+            "audio_file": uploaded_audio_file,
+        },
+        context={"user": user},
+    )
+    assert s.is_valid() is False
+    assert s.errors["non_field_errors"] == ["upload_quota_reached"]
+
+
+def test_manage_track_file_action_delete(factories):
+    tfs = factories["music.TrackFile"](size=5)
+    s = serializers.TrackFileActionSerializer(queryset=None)
+
+    s.handle_delete(tfs.__class__.objects.all())
+
+    assert tfs.__class__.objects.count() == 0
+
+
+def test_manage_track_file_action_relaunch_import(factories, mocker):
+    m = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    # this one is finished and should stay as is
+    finished = factories["music.TrackFile"](import_status="finished")
+
+    to_relaunch = [
+        factories["music.TrackFile"](import_status="pending"),
+        factories["music.TrackFile"](import_status="skipped"),
+        factories["music.TrackFile"](import_status="errored"),
+    ]
+    s = serializers.TrackFileActionSerializer(queryset=None)
+
+    s.handle_relaunch_import(models.TrackFile.objects.all())
+
+    for obj in to_relaunch:
+        obj.refresh_from_db()
+        assert obj.import_status == "pending"
+        m.assert_any_call(tasks.import_track_file.delay, track_file_id=obj.pk)
+
+    finished.refresh_from_db()
+    assert finished.import_status == "finished"
+    assert m.call_count == 3
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index e91594d4727146c45f7cc36123c406e4524a8f5d..48c524ea7ad9379c21f92a9632ad3af7a071e727 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -1,227 +1,212 @@
+import datetime
 import os
-
 import pytest
+import uuid
+
+from django.core.paginator import Paginator
 
-from funkwhale_api.music import tasks
+from funkwhale_api.federation import serializers as federation_serializers
+from funkwhale_api.music import signals, tasks
 
 DATA_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
-def test_set_acoustid_on_track_file(factories, mocker, preferences):
-    preferences["providers_acoustid__api_key"] = "test"
-    track_file = factories["music.TrackFile"](acoustid_track_id=None)
-    id = "e475bf79-c1ce-4441-bed7-1e33f226c0a2"
-    payload = {
-        "results": [
-            {
-                "id": id,
-                "recordings": [
-                    {
-                        "artists": [
-                            {
-                                "id": "9c6bddde-6228-4d9f-ad0d-03f6fcb19e13",
-                                "name": "Binärpilot",
-                            }
-                        ],
-                        "duration": 268,
-                        "id": "f269d497-1cc0-4ae4-a0c4-157ec7d73fcb",
-                        "title": "Bend",
-                    }
-                ],
-                "score": 0.860825,
-            }
-        ],
-        "status": "ok",
-    }
-    m = mocker.patch("acoustid.match", return_value=payload)
-    r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk)
-    track_file.refresh_from_db()
-
-    assert str(track_file.acoustid_track_id) == id
-    assert r == id
-    m.assert_called_once_with("test", track_file.audio_file.path, parse=False)
-
-
-def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
-    track_file = factories["music.TrackFile"](acoustid_track_id=None)
-    payload = {"results": [{"score": 0.79}], "status": "ok"}
-    mocker.patch("acoustid.match", return_value=payload)
-    tasks.set_acoustid_on_track_file(track_file_id=track_file.pk)
-    track_file.refresh_from_db()
-
-    assert track_file.acoustid_track_id is None
-
-
-def test_import_batch_run(factories, mocker):
-    job = factories["music.ImportJob"]()
-    mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay")
-    tasks.import_batch_run(import_batch_id=job.batch.pk)
-
-    mocked_job_run.assert_called_once_with(import_job_id=job.pk)
-
-
-@pytest.mark.skip("Acoustid is disabled")
-def test_import_job_can_run_with_file_and_acoustid(
-    artists, albums, tracks, preferences, factories, mocker
-):
-    preferences["providers_acoustid__api_key"] = "test"
-    path = os.path.join(DATA_DIR, "test.ogg")
-    mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
-    acoustid_payload = {
-        "results": [
-            {
-                "id": "e475bf79-c1ce-4441-bed7-1e33f226c0a2",
-                "recordings": [{"duration": 268, "id": mbid}],
-                "score": 0.860825,
-            }
-        ],
-        "status": "ok",
+# DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files")
+
+
+def test_can_create_track_from_file_metadata_no_mbid(db, mocker):
+    metadata = {
+        "artist": ["Test artist"],
+        "album": ["Test album"],
+        "title": ["Test track"],
+        "TRACKNUMBER": ["4"],
+        "date": ["2012-08-15"],
     }
+    mocker.patch("mutagen.File", return_value=metadata)
     mocker.patch(
-        "funkwhale_api.music.utils.get_audio_file_data",
-        return_value={"bitrate": 42, "length": 43},
-    )
-    mocker.patch(
-        "funkwhale_api.musicbrainz.api.artists.get",
-        return_value=artists["get"]["adhesive_wombat"],
+        "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis"
     )
+    track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg"))
+
+    assert track.title == metadata["title"][0]
+    assert track.mbid is None
+    assert track.position == 4
+    assert track.album.title == metadata["album"][0]
+    assert track.album.mbid is None
+    assert track.album.release_date == datetime.date(2012, 8, 15)
+    assert track.artist.name == metadata["artist"][0]
+    assert track.artist.mbid is None
+
+
+def test_can_create_track_from_file_metadata_mbid(factories, mocker):
+    album = factories["music.Album"]()
+    artist = factories["music.Artist"]()
     mocker.patch(
-        "funkwhale_api.musicbrainz.api.releases.get",
-        return_value=albums["get"]["marsupial"],
+        "funkwhale_api.music.models.Album.get_or_create_from_api",
+        return_value=(album, True),
     )
+
+    album_data = {
+        "release": {
+            "id": album.mbid,
+            "medium-list": [
+                {
+                    "track-list": [
+                        {
+                            "id": "03baca8b-855a-3c05-8f3d-d3235287d84d",
+                            "position": "4",
+                            "number": "4",
+                            "recording": {
+                                "id": "2109e376-132b-40ad-b993-2bb6812e19d4",
+                                "title": "Teen Age Riot",
+                                "artist-credit": [
+                                    {"artist": {"id": artist.mbid, "name": artist.name}}
+                                ],
+                            },
+                        }
+                    ],
+                    "track-count": 1,
+                }
+            ],
+        }
+    }
+    mocker.patch("funkwhale_api.musicbrainz.api.releases.get", return_value=album_data)
+    track_data = album_data["release"]["medium-list"][0]["track-list"][0]
+    metadata = {
+        "musicbrainz_albumid": [album.mbid],
+        "musicbrainz_trackid": [track_data["recording"]["id"]],
+    }
+    mocker.patch("mutagen.File", return_value=metadata)
     mocker.patch(
-        "funkwhale_api.musicbrainz.api.recordings.search",
-        return_value=tracks["search"]["8bitadventures"],
-    )
-    mocker.patch("acoustid.match", return_value=acoustid_payload)
-
-    job = factories["music.FileImportJob"](audio_file__path=path)
-    f = job.audio_file
-    tasks.import_job_run(import_job_id=job.pk)
-    job.refresh_from_db()
-
-    track_file = job.track_file
-
-    with open(path, "rb") as f:
-        assert track_file.audio_file.read() == f.read()
-    assert track_file.bitrate == 42
-    assert track_file.duration == 43
-    assert track_file.size == os.path.getsize(path)
-    # audio file is deleted from import job once persisted to audio file
-    assert not job.audio_file
-    assert job.status == "finished"
-    assert job.source == "file://"
-
-
-def test_run_import_skipping_accoustid(factories, mocker):
-    m = mocker.patch("funkwhale_api.music.tasks._do_import")
-    path = os.path.join(DATA_DIR, "test.ogg")
-    job = factories["music.FileImportJob"](audio_file__path=path)
-    tasks.import_job_run(import_job_id=job.pk, use_acoustid=False)
-    m.assert_called_once_with(job, use_acoustid=False)
-
-
-def test__do_import_skipping_accoustid(factories, mocker):
-    t = factories["music.Track"]()
-    m = mocker.patch(
-        "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path",
-        return_value=t,
+        "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis"
     )
-    path = os.path.join(DATA_DIR, "test.ogg")
-    job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
-    p = job.audio_file.path
-    tasks._do_import(job, use_acoustid=False)
-    m.assert_called_once_with(p)
-
-
-def test__do_import_skipping_accoustid_if_no_key(factories, mocker, preferences):
-    preferences["providers_acoustid__api_key"] = ""
-    t = factories["music.Track"]()
-    m = mocker.patch(
-        "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path",
-        return_value=t,
-    )
-    path = os.path.join(DATA_DIR, "test.ogg")
-    job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
-    p = job.audio_file.path
-    tasks._do_import(job, use_acoustid=False)
-    m.assert_called_once_with(p)
+    track = tasks.import_track_data_from_file(os.path.join(DATA_DIR, "dummy_file.ogg"))
 
+    assert track.title == track_data["recording"]["title"]
+    assert track.mbid == track_data["recording"]["id"]
+    assert track.position == 4
+    assert track.album == album
+    assert track.artist == artist
 
-def test__do_import_replace_if_duplicate(factories, mocker):
-    existing_file = factories["music.TrackFile"]()
-    existing_track = existing_file.track
-    path = os.path.join(DATA_DIR, "test.ogg")
-    mocker.patch(
-        "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path",
-        return_value=existing_track,
+
+def test_track_file_import_mbid(now, factories, temp_signal):
+    track = factories["music.Track"]()
+    tf = factories["music.TrackFile"](
+        track=None, import_metadata={"track": {"mbid": track.mbid}}
     )
-    job = factories["music.FileImportJob"](
-        replace_if_duplicate=True, audio_file__path=path
+
+    with temp_signal(signals.track_file_import_status_updated) as handler:
+        tasks.import_track_file(track_file_id=tf.pk)
+
+    tf.refresh_from_db()
+
+    assert tf.track == track
+    assert tf.import_status == "finished"
+    assert tf.import_date == now
+    handler.assert_called_once_with(
+        track_file=tf,
+        old_status="pending",
+        new_status="finished",
+        sender=None,
+        signal=signals.track_file_import_status_updated,
     )
-    tasks._do_import(job)
-    with pytest.raises(existing_file.__class__.DoesNotExist):
-        existing_file.refresh_from_db()
-    assert existing_file.creation_date != job.track_file.creation_date
 
 
-def test_import_job_skip_if_already_exists(artists, albums, tracks, factories, mocker):
-    path = os.path.join(DATA_DIR, "test.ogg")
-    mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
-    track_file = factories["music.TrackFile"](track__mbid=mbid)
+def test_track_file_import_get_audio_data(factories, mocker):
     mocker.patch(
-        "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path",
-        return_value=track_file.track,
+        "funkwhale_api.music.models.TrackFile.get_audio_data",
+        return_value={"size": 23, "duration": 42, "bitrate": 66},
+    )
+    track = factories["music.Track"]()
+    tf = factories["music.TrackFile"](
+        track=None, import_metadata={"track": {"mbid": track.mbid}}
     )
 
-    job = factories["music.FileImportJob"](audio_file__path=path)
-    tasks.import_job_run(import_job_id=job.pk)
-    job.refresh_from_db()
+    tasks.import_track_file(track_file_id=tf.pk)
 
-    assert job.track_file is None
-    # audio file is deleted from import job once persisted to audio file
-    assert not job.audio_file
-    assert job.status == "skipped"
+    tf.refresh_from_db()
+    assert tf.size == 23
+    assert tf.duration == 42
+    assert tf.bitrate == 66
 
 
-def test_import_job_can_be_errored(factories, mocker, preferences):
-    path = os.path.join(DATA_DIR, "test.ogg")
-    mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"
-    factories["music.TrackFile"](track__mbid=mbid)
+def test_track_file_import_skip_existing_track_in_own_library(factories, temp_signal):
+    track = factories["music.Track"]()
+    library = factories["music.Library"]()
+    existing = factories["music.TrackFile"](
+        track=track,
+        import_status="finished",
+        library=library,
+        import_metadata={"track": {"mbid": track.mbid}},
+    )
+    duplicate = factories["music.TrackFile"](
+        track=track,
+        import_status="pending",
+        library=library,
+        import_metadata={"track": {"mbid": track.mbid}},
+    )
+    with temp_signal(signals.track_file_import_status_updated) as handler:
+        tasks.import_track_file(track_file_id=duplicate.pk)
 
-    class MyException(Exception):
-        pass
+    duplicate.refresh_from_db()
 
-    mocker.patch("funkwhale_api.music.tasks._do_import", side_effect=MyException())
+    assert duplicate.import_status == "skipped"
+    assert duplicate.import_details == {
+        "code": "already_imported_in_owned_libraries",
+        "duplicates": [str(existing.uuid)],
+    }
 
-    job = factories["music.FileImportJob"](audio_file__path=path, track_file=None)
+    handler.assert_called_once_with(
+        track_file=duplicate,
+        old_status="pending",
+        new_status="skipped",
+        sender=None,
+        signal=signals.track_file_import_status_updated,
+    )
 
-    with pytest.raises(MyException):
-        tasks.import_job_run(import_job_id=job.pk)
 
-    job.refresh_from_db()
+def test_track_file_import_track_uuid(now, factories):
+    track = factories["music.Track"]()
+    tf = factories["music.TrackFile"](
+        track=None, import_metadata={"track": {"uuid": track.uuid}}
+    )
 
-    assert job.track_file is None
-    assert job.status == "errored"
+    tasks.import_track_file(track_file_id=tf.pk)
 
+    tf.refresh_from_db()
 
-def test__do_import_calls_update_album_cover_if_no_cover(factories, mocker):
-    path = os.path.join(DATA_DIR, "test.ogg")
-    album = factories["music.Album"](cover="")
-    track = factories["music.Track"](album=album)
+    assert tf.track == track
+    assert tf.import_status == "finished"
+    assert tf.import_date == now
 
-    mocker.patch(
-        "funkwhale_api.providers.audiofile.tasks.import_track_data_from_path",
-        return_value=track,
-    )
 
-    mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
+def test_track_file_import_error(factories, now, temp_signal):
+    tf = factories["music.TrackFile"](import_metadata={"track": {"uuid": uuid.uuid4()}})
+    with temp_signal(signals.track_file_import_status_updated) as handler:
+        tasks.import_track_file(track_file_id=tf.pk)
+    tf.refresh_from_db()
 
-    job = factories["music.FileImportJob"](audio_file__path=path, track_file=None)
+    assert tf.import_status == "errored"
+    assert tf.import_date == now
+    assert tf.import_details == {"error_code": "track_uuid_not_found"}
+    handler.assert_called_once_with(
+        track_file=tf,
+        old_status="pending",
+        new_status="errored",
+        sender=None,
+        signal=signals.track_file_import_status_updated,
+    )
 
-    tasks.import_job_run(import_job_id=job.pk)
 
-    mocked_update.assert_called_once_with(album, track.files.first())
+def test_track_file_import_updates_cover_if_no_cover(factories, mocker, now):
+    mocked_update = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
+    album = factories["music.Album"](cover="")
+    track = factories["music.Track"](album=album)
+    tf = factories["music.TrackFile"](
+        track=None, import_metadata={"track": {"uuid": track.uuid}}
+    )
+    tasks.import_track_file(track_file_id=tf.pk)
+    mocked_update.assert_called_once_with(album, tf)
 
 
 def test_update_album_cover_mbid(factories, mocker):
@@ -263,3 +248,84 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m
     mocked_get.assert_called_once_with(
         data={"mimetype": mimetype, "content": image_content}
     )
+
+
+def test_scan_library_fetches_page_and_calls_scan_page(now, mocker, factories, r_mock):
+    scan = factories["music.LibraryScan"]()
+    collection_conf = {
+        "actor": scan.library.actor,
+        "id": scan.library.fid,
+        "page_size": 10,
+        "items": range(10),
+    }
+    collection = federation_serializers.PaginatedCollectionSerializer(collection_conf)
+    scan_page = mocker.patch("funkwhale_api.music.tasks.scan_library_page.delay")
+    r_mock.get(collection_conf["id"], json=collection.data)
+    tasks.start_library_scan(library_scan_id=scan.pk)
+
+    scan_page.assert_called_once_with(
+        library_scan_id=scan.pk, page_url=collection.data["first"]
+    )
+    scan.refresh_from_db()
+
+    assert scan.status == "scanning"
+    assert scan.total_files == len(collection_conf["items"])
+    assert scan.modification_date == now
+
+
+def test_scan_page_fetches_page_and_creates_tracks(now, mocker, factories, r_mock):
+    scan_page = mocker.patch("funkwhale_api.music.tasks.scan_library_page.delay")
+    import_tf = mocker.patch("funkwhale_api.music.tasks.import_track_file.delay")
+    scan = factories["music.LibraryScan"](status="scanning", total_files=5)
+    tfs = factories["music.TrackFile"].build_batch(size=5, library=scan.library)
+    for i, tf in enumerate(tfs):
+        tf.fid = "https://track.test/{}".format(i)
+
+    page_conf = {
+        "actor": scan.library.actor,
+        "id": scan.library.fid,
+        "page": Paginator(tfs, 3).page(1),
+        "item_serializer": federation_serializers.AudioSerializer,
+    }
+    page = federation_serializers.CollectionPageSerializer(page_conf)
+    r_mock.get(page.data["id"], json=page.data)
+
+    tasks.scan_library_page(library_scan_id=scan.pk, page_url=page.data["id"])
+
+    scan.refresh_from_db()
+    lts = list(scan.library.files.all().order_by("-creation_date"))
+
+    assert len(lts) == 3
+    for tf in tfs[:3]:
+        new_tf = scan.library.files.get(fid=tf.get_federation_id())
+        import_tf.assert_any_call(track_file_id=new_tf.pk)
+
+    assert scan.status == "scanning"
+    assert scan.processed_files == 3
+    assert scan.modification_date == now
+
+    scan_page.assert_called_once_with(
+        library_scan_id=scan.pk, page_url=page.data["next"]
+    )
+
+
+def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock):
+    patched_scan = mocker.patch("funkwhale_api.music.tasks.scan_library_page.delay")
+    scan = factories["music.LibraryScan"](status="scanning", total_files=5)
+    tfs = factories["music.TrackFile"].build_batch(size=5, library=scan.library)
+    page_conf = {
+        "actor": scan.library.actor,
+        "id": scan.library.fid,
+        "page": Paginator(tfs, 3).page(1),
+        "item_serializer": federation_serializers.AudioSerializer,
+    }
+    page = federation_serializers.CollectionPageSerializer(page_conf)
+    data = page.data
+    data["next"] = data["id"]
+    r_mock.get(page.data["id"], json=data)
+
+    tasks.scan_library_page(library_scan_id=scan.pk, page_url=data["id"])
+    patched_scan.assert_not_called()
+    scan.refresh_from_db()
+
+    assert scan.status == "finished"
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index aa04521cb571583ba8ce26511c12fe6bb5f2b518..ff49f24648f972fd957de73a7d6f257353ff10f2 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -1,26 +1,17 @@
 import io
+import os
 
 import pytest
 from django.urls import reverse
 from django.utils import timezone
 
-from funkwhale_api.federation import actors
-from funkwhale_api.music import serializers, views
+from funkwhale_api.music import serializers, tasks, views
 
-
-@pytest.mark.parametrize(
-    "view,permissions,operator",
-    [
-        (views.ImportBatchViewSet, ["library", "upload"], "or"),
-        (views.ImportJobViewSet, ["library", "upload"], "or"),
-    ],
-)
-def test_permissions(assert_user_permission, view, permissions, operator):
-    assert_user_permission(view, permissions, operator)
+DATA_DIR = os.path.dirname(os.path.abspath(__file__))
 
 
 def test_artist_list_serializer(api_request, factories, logged_in_api_client):
-    track = factories["music.Track"]()
+    track = factories["music.TrackFile"](library__privacy_level="everyone").track
     artist = track.artist
     request = api_request.get("/")
     qs = artist.__class__.objects.with_albums()
@@ -36,7 +27,7 @@ def test_artist_list_serializer(api_request, factories, logged_in_api_client):
 
 
 def test_album_list_serializer(api_request, factories, logged_in_api_client):
-    track = factories["music.Track"]()
+    track = factories["music.TrackFile"](library__privacy_level="everyone").track
     album = track.album
     request = api_request.get("/")
     qs = album.__class__.objects.all()
@@ -44,21 +35,24 @@ def test_album_list_serializer(api_request, factories, logged_in_api_client):
         qs, many=True, context={"request": request}
     )
     expected = {"count": 1, "next": None, "previous": None, "results": serializer.data}
+    expected["results"][0]["is_playable"] = True
+    expected["results"][0]["tracks"][0]["is_playable"] = True
     url = reverse("api:v1:albums-list")
     response = logged_in_api_client.get(url)
 
     assert response.status_code == 200
-    assert response.data == expected
+    assert response.data["results"][0] == expected["results"][0]
 
 
 def test_track_list_serializer(api_request, factories, logged_in_api_client):
-    track = factories["music.Track"]()
+    track = factories["music.TrackFile"](library__privacy_level="everyone").track
     request = api_request.get("/")
     qs = track.__class__.objects.all()
     serializer = serializers.TrackSerializer(
         qs, many=True, context={"request": request}
     )
     expected = {"count": 1, "next": None, "previous": None, "results": serializer.data}
+    expected["results"][0]["is_playable"] = True
     url = reverse("api:v1:tracks-list")
     response = logged_in_api_client.get(url)
 
@@ -67,13 +61,15 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client):
 
 
 @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")])
-def test_artist_view_filter_listenable(param, expected, factories, api_request):
+def test_artist_view_filter_playable(param, expected, factories, api_request):
     artists = {
         "empty": factories["music.Artist"](),
-        "full": factories["music.TrackFile"]().track.artist,
+        "full": factories["music.TrackFile"](
+            library__privacy_level="everyone"
+        ).track.artist,
     }
 
-    request = api_request.get("/", {"listenable": param})
+    request = api_request.get("/", {"playable": param})
     view = views.ArtistViewSet()
     view.action_map = {"get": "list"}
     expected = [artists[expected]]
@@ -84,13 +80,15 @@ def test_artist_view_filter_listenable(param, expected, factories, api_request):
 
 
 @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")])
-def test_album_view_filter_listenable(param, expected, factories, api_request):
+def test_album_view_filter_playable(param, expected, factories, api_request):
     artists = {
         "empty": factories["music.Album"](),
-        "full": factories["music.TrackFile"]().track.album,
+        "full": factories["music.TrackFile"](
+            library__privacy_level="everyone"
+        ).track.album,
     }
 
-    request = api_request.get("/", {"listenable": param})
+    request = api_request.get("/", {"playable": param})
     view = views.AlbumViewSet()
     view.action_map = {"get": "list"}
     expected = [artists[expected]]
@@ -101,16 +99,16 @@ def test_album_view_filter_listenable(param, expected, factories, api_request):
 
 
 def test_can_serve_track_file_as_remote_library(
-    factories, authenticated_actor, api_client, settings, preferences
+    factories, authenticated_actor, logged_in_api_client, settings, preferences
 ):
     preferences["common__api_authentication_required"] = True
-    library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
+    track_file = factories["music.TrackFile"](library__privacy_level="everyone")
+    library_actor = track_file.library.actor
     factories["federation.Follow"](
         approved=True, actor=authenticated_actor, target=library_actor
     )
 
-    track_file = factories["music.TrackFile"]()
-    response = api_client.get(track_file.path)
+    response = logged_in_api_client.get(track_file.track.listen_url)
 
     assert response.status_code == 200
     assert response["X-Accel-Redirect"] == "{}{}".format(
@@ -122,8 +120,8 @@ def test_can_serve_track_file_as_remote_library_deny_not_following(
     factories, authenticated_actor, settings, api_client, preferences
 ):
     preferences["common__api_authentication_required"] = True
-    track_file = factories["music.TrackFile"]()
-    response = api_client.get(track_file.path)
+    track_file = factories["music.TrackFile"](library__privacy_level="everyone")
+    response = api_client.get(track_file.track.listen_url)
 
     assert response.status_code == 403
 
@@ -147,9 +145,11 @@ def test_serve_file_in_place(
     settings.MUSIC_DIRECTORY_PATH = "/app/music"
     settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
     tf = factories["music.TrackFile"](
-        in_place=True, source="file:///app/music/hello/world.mp3"
+        in_place=True,
+        source="file:///app/music/hello/world.mp3",
+        library__privacy_level="everyone",
     )
-    response = api_client.get(tf.path)
+    response = api_client.get(tf.track.listen_url)
 
     assert response.status_code == 200
     assert response[headers[proxy]] == expected
@@ -198,9 +198,9 @@ def test_serve_file_media(
     settings.MUSIC_DIRECTORY_PATH = "/app/music"
     settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
 
-    tf = factories["music.TrackFile"]()
+    tf = factories["music.TrackFile"](library__privacy_level="everyone")
     tf.__class__.objects.filter(pk=tf.pk).update(audio_file="tracks/hello/world.mp3")
-    response = api_client.get(tf.path)
+    response = api_client.get(tf.track.listen_url)
 
     assert response.status_code == 200
     assert response[headers[proxy]] == expected
@@ -208,146 +208,179 @@ def test_serve_file_media(
 
 def test_can_proxy_remote_track(factories, settings, api_client, r_mock, preferences):
     preferences["common__api_authentication_required"] = False
-    track_file = factories["music.TrackFile"](federation=True)
+    url = "https://file.test"
+    track_file = factories["music.TrackFile"](
+        library__privacy_level="everyone", audio_file="", source=url
+    )
 
-    r_mock.get(track_file.library_track.audio_url, body=io.BytesIO(b"test"))
-    response = api_client.get(track_file.path)
+    r_mock.get(url, body=io.BytesIO(b"test"))
+    response = api_client.get(track_file.track.listen_url)
+    track_file.refresh_from_db()
 
-    library_track = track_file.library_track
-    library_track.refresh_from_db()
     assert response.status_code == 200
     assert response["X-Accel-Redirect"] == "{}{}".format(
-        settings.PROTECT_FILES_PATH, library_track.audio_file.url
+        settings.PROTECT_FILES_PATH, track_file.audio_file.url
     )
-    assert library_track.audio_file.read() == b"test"
+    assert track_file.audio_file.read() == b"test"
 
 
 def test_serve_updates_access_date(factories, settings, api_client, preferences):
     preferences["common__api_authentication_required"] = False
-    track_file = factories["music.TrackFile"]()
+    track_file = factories["music.TrackFile"](library__privacy_level="everyone")
     now = timezone.now()
     assert track_file.accessed_date is None
 
-    response = api_client.get(track_file.path)
+    response = api_client.get(track_file.track.listen_url)
     track_file.refresh_from_db()
 
     assert response.status_code == 200
     assert track_file.accessed_date > now
 
 
-def test_can_list_import_jobs(factories, superuser_api_client):
-    job = factories["music.ImportJob"]()
-    url = reverse("api:v1:import-jobs-list")
-    response = superuser_api_client.get(url)
+def test_listen_no_track(factories, logged_in_api_client):
+    url = reverse("api:v1:listen-detail", kwargs={"uuid": "noop"})
+    response = logged_in_api_client.get(url)
 
-    assert response.status_code == 200
-    assert response.data["results"][0]["id"] == job.pk
+    assert response.status_code == 404
 
 
-def test_import_job_stats(factories, superuser_api_client):
-    factories["music.ImportJob"](status="pending")
-    factories["music.ImportJob"](status="errored")
+def test_listen_no_file(factories, logged_in_api_client):
+    track = factories["music.Track"]()
+    url = reverse("api:v1:listen-detail", kwargs={"uuid": track.uuid})
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 404
 
-    url = reverse("api:v1:import-jobs-stats")
-    response = superuser_api_client.get(url)
-    expected = {"errored": 1, "pending": 1, "finished": 0, "skipped": 0, "count": 2}
-    assert response.status_code == 200
-    assert response.data == expected
 
+def test_listen_no_available_file(factories, logged_in_api_client):
+    tf = factories["music.TrackFile"]()
+    url = reverse("api:v1:listen-detail", kwargs={"uuid": tf.track.uuid})
+    response = logged_in_api_client.get(url)
 
-def test_import_job_stats_filter(factories, superuser_api_client):
-    job1 = factories["music.ImportJob"](status="pending")
-    factories["music.ImportJob"](status="errored")
+    assert response.status_code == 404
 
-    url = reverse("api:v1:import-jobs-stats")
-    response = superuser_api_client.get(url, {"batch": job1.batch.pk})
-    expected = {"errored": 0, "pending": 1, "finished": 0, "skipped": 0, "count": 1}
-    assert response.status_code == 200
-    assert response.data == expected
 
+def test_listen_correct_access(factories, logged_in_api_client):
+    logged_in_api_client.user.create_actor()
+    tf = factories["music.TrackFile"](
+        library__actor=logged_in_api_client.user.actor, library__privacy_level="me"
+    )
+    url = reverse("api:v1:listen-detail", kwargs={"uuid": tf.track.uuid})
+    response = logged_in_api_client.get(url)
 
-def test_import_job_run_via_api(factories, superuser_api_client, mocker):
-    run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay")
-    job1 = factories["music.ImportJob"](status="errored")
-    job2 = factories["music.ImportJob"](status="pending")
+    assert response.status_code == 200
 
-    url = reverse("api:v1:import-jobs-run")
-    response = superuser_api_client.post(url, {"jobs": [job2.pk, job1.pk]})
 
-    job1.refresh_from_db()
-    job2.refresh_from_db()
+def test_listen_explicit_file(factories, logged_in_api_client, mocker):
+    mocked_serve = mocker.spy(views, "handle_serve")
+    tf1 = factories["music.TrackFile"](library__privacy_level="everyone")
+    tf2 = factories["music.TrackFile"](
+        library__privacy_level="everyone", track=tf1.track
+    )
+    url = reverse("api:v1:listen-detail", kwargs={"uuid": tf2.track.uuid})
+    response = logged_in_api_client.get(url, {"file": tf2.uuid})
+
     assert response.status_code == 200
-    assert response.data == {"jobs": [job1.pk, job2.pk]}
-    assert job1.status == "pending"
-    assert job2.status == "pending"
+    mocked_serve.assert_called_once_with(tf2, user=logged_in_api_client.user)
+
 
-    run.assert_any_call(import_job_id=job1.pk)
-    run.assert_any_call(import_job_id=job2.pk)
+def test_user_can_create_library(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    url = reverse("api:v1:libraries-list")
 
+    response = logged_in_api_client.post(
+        url, {"name": "hello", "description": "world", "privacy_level": "me"}
+    )
+    library = actor.libraries.first()
+
+    assert response.status_code == 201
 
-def test_import_batch_run_via_api(factories, superuser_api_client, mocker):
-    run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay")
+    assert library.actor == actor
+    assert library.name == "hello"
+    assert library.description == "world"
+    assert library.privacy_level == "me"
+    assert library.fid == library.get_federation_id()
+    assert library.followers_url == library.fid + "/followers"
 
-    batch = factories["music.ImportBatch"]()
-    job1 = factories["music.ImportJob"](batch=batch, status="errored")
-    job2 = factories["music.ImportJob"](batch=batch, status="pending")
 
-    url = reverse("api:v1:import-jobs-run")
-    response = superuser_api_client.post(url, {"batches": [batch.pk]})
+def test_user_can_list_their_library(factories, logged_in_api_client):
+    actor = logged_in_api_client.user.create_actor()
+    library = factories["music.Library"](actor=actor)
+    factories["music.Library"]()
+
+    url = reverse("api:v1:libraries-list")
+    response = logged_in_api_client.get(url)
 
-    job1.refresh_from_db()
-    job2.refresh_from_db()
     assert response.status_code == 200
-    assert job1.status == "pending"
-    assert job2.status == "pending"
+    assert response.data["count"] == 1
+    assert response.data["results"][0]["uuid"] == str(library.uuid)
 
-    run.assert_any_call(import_job_id=job1.pk)
-    run.assert_any_call(import_job_id=job2.pk)
 
+def test_user_cannot_delete_other_actors_library(factories, logged_in_api_client):
+    logged_in_api_client.user.create_actor()
+    library = factories["music.Library"](privacy_level="everyone")
 
-def test_import_batch_and_job_run_via_api(factories, superuser_api_client, mocker):
-    run = mocker.patch("funkwhale_api.music.tasks.import_job_run.delay")
+    url = reverse("api:v1:libraries-detail", kwargs={"uuid": library.uuid})
+    response = logged_in_api_client.delete(url)
 
-    batch = factories["music.ImportBatch"]()
-    job1 = factories["music.ImportJob"](batch=batch, status="errored")
-    job2 = factories["music.ImportJob"](status="pending")
+    assert response.status_code == 404
 
-    url = reverse("api:v1:import-jobs-run")
-    response = superuser_api_client.post(
-        url, {"batches": [batch.pk], "jobs": [job2.pk]}
-    )
 
-    job1.refresh_from_db()
-    job2.refresh_from_db()
-    assert response.status_code == 200
-    assert job1.status == "pending"
-    assert job2.status == "pending"
+def test_user_cannot_get_other_actors_files(factories, logged_in_api_client):
+    logged_in_api_client.user.create_actor()
+    track_file = factories["music.TrackFile"]()
 
-    run.assert_any_call(import_job_id=job1.pk)
-    run.assert_any_call(import_job_id=job2.pk)
+    url = reverse("api:v1:trackfiles-detail", kwargs={"uuid": track_file.uuid})
+    response = logged_in_api_client.get(url)
 
+    assert response.status_code == 404
+
+
+def test_user_cannot_delete_other_actors_files(factories, logged_in_api_client):
+    logged_in_api_client.user.create_actor()
+    track_file = factories["music.TrackFile"]()
+
+    url = reverse("api:v1:trackfiles-detail", kwargs={"uuid": track_file.uuid})
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 404
 
-def test_import_job_viewset_get_queryset_upload_filters_user(
-    factories, logged_in_api_client
-):
-    logged_in_api_client.user.permission_upload = True
-    logged_in_api_client.user.save()
 
-    factories["music.ImportJob"]()
-    url = reverse("api:v1:import-jobs-list")
+def test_user_cannot_list_other_actors_files(factories, logged_in_api_client):
+    logged_in_api_client.user.create_actor()
+    factories["music.TrackFile"]()
+
+    url = reverse("api:v1:trackfiles-list")
     response = logged_in_api_client.get(url)
 
+    assert response.status_code == 200
     assert response.data["count"] == 0
 
 
-def test_import_batch_viewset_get_queryset_upload_filters_user(
-    factories, logged_in_api_client
+def test_user_can_create_track_file(
+    logged_in_api_client, factories, mocker, audio_file
 ):
-    logged_in_api_client.user.permission_upload = True
-    logged_in_api_client.user.save()
+    library = factories["music.Library"](actor__user=logged_in_api_client.user)
+    url = reverse("api:v1:trackfiles-list")
+    m = mocker.patch("funkwhale_api.common.utils.on_commit")
+
+    response = logged_in_api_client.post(
+        url,
+        {
+            "audio_file": audio_file,
+            "source": "upload://test",
+            "import_reference": "test",
+            "library": library.uuid,
+        },
+    )
 
-    factories["music.ImportBatch"]()
-    url = reverse("api:v1:import-batches-list")
-    response = logged_in_api_client.get(url)
+    assert response.status_code == 201
 
-    assert response.data["count"] == 0
+    tf = library.files.latest("id")
+
+    audio_file.seek(0)
+    assert tf.audio_file.read() == audio_file.read()
+    assert tf.source == "upload://test"
+    assert tf.import_reference == "test"
+    assert tf.track is None
+    m.assert_called_once_with(tasks.import_track_file.delay, track_file_id=tf.pk)
diff --git a/api/tests/requests/test_models.py b/api/tests/requests/test_models.py
index 3ac8a534207acc5152b18ed0eedc8c429463f345..9b9100b5cdfce0eb8370a71e9ff7496eb9aa2774 100644
--- a/api/tests/requests/test_models.py
+++ b/api/tests/requests/test_models.py
@@ -1,3 +1,7 @@
+import pytest
+
+
+@pytest.mark.skip(reason="Refactoring in progress")
 def test_can_bind_import_batch_to_request(factories):
     request = factories["requests.ImportRequest"]()
 
diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py
index 1c7c528ccb8f00afbbe15719b1c06dc9bfde22ac..e2bdcfca468216cf8e350207d304aa78b805c3bd 100644
--- a/api/tests/subsonic/test_views.py
+++ b/api/tests/subsonic/test_views.py
@@ -165,7 +165,7 @@ def test_stream(f, db, logged_in_api_client, factories, mocker):
     tf = factories["music.TrackFile"](track=track)
     response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
 
-    mocked_serve.assert_called_once_with(track_file=tf)
+    mocked_serve.assert_called_once_with(track_file=tf, user=logged_in_api_client.user)
     assert response.status_code == 200
 
 
diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py
index f63e69b635cad1b4534a8f7c1a3aabc6b636fd15..28197731603b6b0965c2515ecef819e8d7647a9e 100644
--- a/api/tests/test_import_audio_file.py
+++ b/api/tests/test_import_audio_file.py
@@ -1,91 +1,14 @@
-import datetime
 import os
 
 import pytest
 from django.core.management import call_command
 from django.core.management.base import CommandError
 
-from funkwhale_api.providers.audiofile import tasks
 from funkwhale_api.music.models import ImportJob
 
 DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files")
 
 
-def test_can_create_track_from_file_metadata_no_mbid(db, mocker):
-    metadata = {
-        "artist": ["Test artist"],
-        "album": ["Test album"],
-        "title": ["Test track"],
-        "TRACKNUMBER": ["4"],
-        "date": ["2012-08-15"],
-    }
-    mocker.patch("mutagen.File", return_value=metadata)
-    mocker.patch(
-        "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis"
-    )
-    track = tasks.import_track_data_from_path(os.path.join(DATA_DIR, "dummy_file.ogg"))
-
-    assert track.title == metadata["title"][0]
-    assert track.mbid is None
-    assert track.position == 4
-    assert track.album.title == metadata["album"][0]
-    assert track.album.mbid is None
-    assert track.album.release_date == datetime.date(2012, 8, 15)
-    assert track.artist.name == metadata["artist"][0]
-    assert track.artist.mbid is None
-
-
-def test_can_create_track_from_file_metadata_mbid(factories, mocker):
-    album = factories["music.Album"]()
-    artist = factories["music.Artist"]()
-    mocker.patch(
-        "funkwhale_api.music.models.Album.get_or_create_from_api",
-        return_value=(album, True),
-    )
-
-    album_data = {
-        "release": {
-            "id": album.mbid,
-            "medium-list": [
-                {
-                    "track-list": [
-                        {
-                            "id": "03baca8b-855a-3c05-8f3d-d3235287d84d",
-                            "position": "4",
-                            "number": "4",
-                            "recording": {
-                                "id": "2109e376-132b-40ad-b993-2bb6812e19d4",
-                                "title": "Teen Age Riot",
-                                "artist-credit": [
-                                    {"artist": {"id": artist.mbid, "name": artist.name}}
-                                ],
-                            },
-                        }
-                    ],
-                    "track-count": 1,
-                }
-            ],
-        }
-    }
-    mocker.patch("funkwhale_api.musicbrainz.api.releases.get", return_value=album_data)
-    track_data = album_data["release"]["medium-list"][0]["track-list"][0]
-    metadata = {
-        "musicbrainz_albumid": [album.mbid],
-        "musicbrainz_trackid": [track_data["recording"]["id"]],
-    }
-    mocker.patch("mutagen.File", return_value=metadata)
-    mocker.patch(
-        "funkwhale_api.music.metadata.Metadata.get_file_type", return_value="OggVorbis"
-    )
-    track = tasks.import_track_data_from_path(os.path.join(DATA_DIR, "dummy_file.ogg"))
-
-    assert track.title == track_data["recording"]["title"]
-    assert track.mbid == track_data["recording"]["id"]
-    assert track.position == 4
-    assert track.album == album
-    assert track.artist == artist
-
-
 def test_management_command_requires_a_valid_username(factories, mocker):
     path = os.path.join(DATA_DIR, "dummy_file.ogg")
     factories["users.User"](username="me")
@@ -120,6 +43,7 @@ def test_import_with_multiple_argument(factories, mocker):
     mocked_filter.assert_called_once_with([path1, path2])
 
 
+@pytest.mark.skip("Refactoring in progress")
 def test_import_with_replace_flag(factories, mocker):
     factories["users.User"](username="me")
     path = os.path.join(DATA_DIR, "dummy_file.ogg")
@@ -133,6 +57,7 @@ def test_import_with_replace_flag(factories, mocker):
     )
 
 
+@pytest.mark.skip("Refactoring in progress")
 def test_import_files_creates_a_batch_and_job(factories, mocker):
     m = mocker.patch("funkwhale_api.music.tasks.import_job_run")
     user = factories["users.User"](username="me")
@@ -162,6 +87,7 @@ def test_import_files_skip_if_path_already_imported(factories, mocker):
     assert user.imports.count() == 0
 
 
+@pytest.mark.skip("Refactoring in progress")
 def test_import_files_works_with_utf8_file_name(factories, mocker):
     m = mocker.patch("funkwhale_api.music.tasks.import_job_run")
     user = factories["users.User"](username="me")
@@ -172,6 +98,7 @@ def test_import_files_works_with_utf8_file_name(factories, mocker):
     m.assert_called_once_with(import_job_id=job.pk, use_acoustid=False)
 
 
+@pytest.mark.skip("Refactoring in progress")
 def test_import_files_in_place(factories, mocker, settings):
     settings.MUSIC_DIRECTORY_PATH = DATA_DIR
     m = mocker.patch("funkwhale_api.music.tasks.import_job_run")
diff --git a/api/tests/test_youtube.py b/api/tests/test_youtube.py
deleted file mode 100644
index cb5559ce1c2a24fc16e5bdbc326fa3e6c717110d..0000000000000000000000000000000000000000
--- a/api/tests/test_youtube.py
+++ /dev/null
@@ -1,96 +0,0 @@
-from collections import OrderedDict
-
-from django.urls import reverse
-
-from funkwhale_api.providers.youtube.client import client
-
-from .data import youtube as api_data
-
-
-def test_can_get_search_results_from_youtube(mocker):
-    mocker.patch(
-        "funkwhale_api.providers.youtube.client._do_search",
-        return_value=api_data.search["8 bit adventure"],
-    )
-    query = "8 bit adventure"
-    results = client.search(query)
-    assert results[0]["id"]["videoId"] == "0HxZn6CzOIo"
-    assert results[0]["snippet"]["title"] == "AdhesiveWombat - 8 Bit Adventure"
-    assert results[0]["full_url"] == "https://www.youtube.com/watch?v=0HxZn6CzOIo"
-
-
-def test_can_get_search_results_from_funkwhale(preferences, mocker, api_client, db):
-    preferences["common__api_authentication_required"] = False
-    mocker.patch(
-        "funkwhale_api.providers.youtube.client._do_search",
-        return_value=api_data.search["8 bit adventure"],
-    )
-    query = "8 bit adventure"
-    url = reverse("api:v1:providers:youtube:search")
-    response = api_client.get(url, {"query": query})
-    # we should cast the youtube result to something more generic
-    expected = {
-        "id": "0HxZn6CzOIo",
-        "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
-        "type": "youtube#video",
-        "description": "Description",
-        "channelId": "UCps63j3krzAG4OyXeEyuhFw",
-        "title": "AdhesiveWombat - 8 Bit Adventure",
-        "channelTitle": "AdhesiveWombat",
-        "publishedAt": "2012-08-22T18:41:03.000Z",
-        "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg",
-    }
-
-    assert response.data[0] == expected
-
-
-def test_can_send_multiple_queries_at_once(mocker):
-    mocker.patch(
-        "funkwhale_api.providers.youtube.client._do_search",
-        side_effect=[
-            api_data.search["8 bit adventure"],
-            api_data.search["system of a down toxicity"],
-        ],
-    )
-
-    queries = OrderedDict()
-    queries["1"] = {"q": "8 bit adventure"}
-    queries["2"] = {"q": "system of a down toxicity"}
-
-    results = client.search_multiple(queries)
-
-    assert results["1"][0]["id"]["videoId"] == "0HxZn6CzOIo"
-    assert results["1"][0]["snippet"]["title"] == "AdhesiveWombat - 8 Bit Adventure"
-    assert results["1"][0]["full_url"] == "https://www.youtube.com/watch?v=0HxZn6CzOIo"
-    assert results["2"][0]["id"]["videoId"] == "BorYwGi2SJc"
-    assert results["2"][0]["snippet"]["title"] == "System of a Down: Toxicity"
-    assert results["2"][0]["full_url"] == "https://www.youtube.com/watch?v=BorYwGi2SJc"
-
-
-def test_can_send_multiple_queries_at_once_from_funwkhale(
-    preferences, mocker, db, api_client
-):
-    preferences["common__api_authentication_required"] = False
-    mocker.patch(
-        "funkwhale_api.providers.youtube.client._do_search",
-        return_value=api_data.search["8 bit adventure"],
-    )
-    queries = OrderedDict()
-    queries["1"] = {"q": "8 bit adventure"}
-
-    expected = {
-        "id": "0HxZn6CzOIo",
-        "url": "https://www.youtube.com/watch?v=0HxZn6CzOIo",
-        "type": "youtube#video",
-        "description": "Description",
-        "channelId": "UCps63j3krzAG4OyXeEyuhFw",
-        "title": "AdhesiveWombat - 8 Bit Adventure",
-        "channelTitle": "AdhesiveWombat",
-        "publishedAt": "2012-08-22T18:41:03.000Z",
-        "cover": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg",
-    }
-
-    url = reverse("api:v1:providers:youtube:searchs")
-    response = api_client.post(url, queries, format="json")
-
-    assert expected == response.data["1"][0]
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index 0d03c0fc2bd06942c17d43a2ee7ccce40779cb7c..5ec0cc0d2c24c47835546c22d95e9cc43d6300c8 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -141,7 +141,7 @@ def test_creating_actor_from_user(factories, settings):
     assert actor.type == "Person"
     assert actor.name == user.username
     assert actor.manually_approves_followers is False
-    assert actor.url == federation_utils.full_url(
+    assert actor.fid == federation_utils.full_url(
         reverse(
             "federation:actors-detail",
             kwargs={"preferred_username": actor.preferred_username},
@@ -165,3 +165,46 @@ def test_creating_actor_from_user(factories, settings):
             kwargs={"preferred_username": actor.preferred_username},
         )
     )
+
+
+def test_get_channels_groups(factories):
+    user = factories["users.User"]()
+
+    assert user.get_channels_groups() == ["user.{}.imports".format(user.pk)]
+
+
+def test_user_quota_default_to_preference(factories, preferences):
+    preferences["users__upload_quota"] = 42
+
+    user = factories["users.User"]()
+    assert user.get_upload_quota() == 42
+
+
+def test_user_quota_set_on_user(factories, preferences):
+    preferences["users__upload_quota"] = 42
+
+    user = factories["users.User"](upload_quota=66)
+    assert user.get_upload_quota() == 66
+
+
+def test_user_get_quota_status(factories, preferences, mocker):
+    user = factories["users.User"](upload_quota=66, with_actor=True)
+    mocker.patch(
+        "funkwhale_api.federation.models.Actor.get_current_usage",
+        return_value={
+            "total": 10 * 1000 * 1000,
+            "pending": 1 * 1000 * 1000,
+            "skipped": 2 * 1000 * 1000,
+            "errored": 3 * 1000 * 1000,
+            "finished": 4 * 1000 * 1000,
+        },
+    )
+    assert user.get_quota_status() == {
+        "max": 66,
+        "remaining": 56,
+        "current": 10,
+        "pending": 1,
+        "skipped": 2,
+        "errored": 3,
+        "finished": 4,
+    }
diff --git a/dev.yml b/dev.yml
index 1d9cbba203a2c898cdb83977256760d48153ff5a..e96672393ed11eca3530d4050b6ac8053e2827b4 100644
--- a/dev.yml
+++ b/dev.yml
@@ -8,9 +8,6 @@ services:
       - .env
     environment:
       - "HOST=0.0.0.0"
-      - "VUE_PORT=${VUE_PORT-8080}"
-    ports:
-      - "${VUE_PORT_BINDING-8080:}${VUE_PORT-8080}"
     volumes:
       - "./front:/app"
       - "/app/node_modules"
diff --git a/front/src/App.vue b/front/src/App.vue
index 0bfdeb8b2acacb447cbbe53a0c4f8a59bc5c02da..514f52d1c944d3b36d88a911659c04d808d7d8ff 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -318,4 +318,8 @@ html, body {
   margin:  0.5em;
 }
 
+a {
+  cursor: pointer;
+}
+
 </style>
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 803518528841c08fa7297e84e6c8c5a0b017b82d..a8a3205cf4d9852da79d6ffb8ab55f68d705bd3b 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -59,6 +59,9 @@
             <router-link
               v-if="$store.state.auth.authenticated"
               class="item" :to="{path: '/activity'}"><i class="bell icon"></i><translate>Activity</translate></router-link>
+            <router-link
+              v-if="$store.state.auth.authenticated"
+              class="item" :to="{name: 'content.index'}"><i class="upload icon"></i><translate>Add content</translate></router-link>
           </div>
         </div>
         <div class="item" v-if="showAdmin">
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index 58a58e7b1dbe30681364ddee7c94658042cd7eb8..3510852b24607b652c901e2bf5743d003d0833bc 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -37,7 +37,8 @@ export default {
     dropdownOnly: {type: Boolean, default: false},
     iconOnly: {type: Boolean, default: false},
     artist: {type: Number, required: false},
-    album: {type: Number, required: false}
+    album: {type: Number, required: false},
+    isPlayable: {type: Boolean, required: false, default: null}
   },
   data () {
     return {
@@ -60,21 +61,20 @@ export default {
         return this.$gettext('Play immediatly')
       } else {
         if (this.track) {
-          return this.$gettext('This track is not imported and cannot be played')
+          return this.$gettext('This track is not available in any library you have access to')
         }
       }
     },
     playable () {
+      if (this.isPlayable) {
+        return true
+      }
       if (this.track) {
-        return this.track.files.length > 0
+        return this.track.is_playable
       } else if (this.tracks) {
-        return this.tracks.length > 0
-      } else if (this.playlist) {
-        return true
-      } else if (this.artist) {
-        return true
-      } else if (this.album) {
-        return true
+        return this.tracks.filter((t) => {
+          return t.is_playable
+        }).length > 0
       }
       return false
     }
@@ -130,7 +130,7 @@ export default {
           self.isLoading = false
         }, 250)
         return tracks.filter(e => {
-          return e.files.length > 0
+          return e.is_playable === true
         })
       })
     },
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
index e22cb62c79995a799c2d02b85f2b1a7fe029173e..5626dd4d89bf41b64563aa93faa799d323f4051f 100644
--- a/front/src/components/audio/Track.vue
+++ b/front/src/components/audio/Track.vue
@@ -29,6 +29,7 @@ export default {
     let self = this
     this.sound = new Howl({
       src: this.srcs.map((s) => { return s.url }),
+      format: this.srcs.map((s) => { return s.type }),
       autoplay: false,
       loop: false,
       html5: true,
@@ -66,13 +67,13 @@ export default {
       looping: state => state.player.looping
     }),
     srcs: function () {
-      let file = this.track.files[0]
-      if (!file) {
-        this.$store.dispatch('player/trackErrored')
-        return []
-      }
+      // let file = this.track.files[0]
+      // if (!file) {
+      //   this.$store.dispatch('player/trackErrored')
+      //   return []
+      // }
       let sources = [
-        {type: file.mimetype, url: this.$store.getters['instance/absoluteUrl'](file.path)}
+        {type: 'mp3', url: this.$store.getters['instance/absoluteUrl'](this.track.listen_url)}
       ]
       if (this.$store.state.auth.authenticated) {
         // we need to send the token directly in url
diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue
index fa71808b3756b450d7d383c0e922b9410af245a3..a52a0dc874375db2c2409d0bfadf7285ca4c5593 100644
--- a/front/src/components/audio/album/Widget.vue
+++ b/front/src/components/audio/album/Widget.vue
@@ -1,5 +1,5 @@
 <template>
-  <div>
+  <div class="wrapper">
     <h3 class="ui header">
       <slot name="title"></slot>
     </h3>
@@ -14,7 +14,7 @@
       </div>
       <div class="card" v-for="album in albums" :key="album.id">
         <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover.original}]" :style="getImageStyle(album)">
-          <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button>
+          <play-button class="play-overlay" :icon-only="true" :is-playable="album.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button>
         </div>
         <div class="content">
           <router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}">
@@ -116,6 +116,9 @@ export default {
   background-image: url('../../../assets/audio/default-cover.png') !important;
 }
 
+.wrapper {
+  width: 100%;
+}
 .ui.cards {
   justify-content: center;
 }
diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue
index dd32b4735310d46de665da5aa0da6cf75af7a113..5f8cfe10a5c9954fd909d2082a4f767f26ea3195 100644
--- a/front/src/components/audio/artist/Card.vue
+++ b/front/src/components/audio/artist/Card.vue
@@ -15,13 +15,13 @@
                   <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
                 </td>
                 <td colspan="4">
-                  <router-link class="discrete link":to="{name: 'library.albums.detail', params: {id: album.id }}">
+                  <router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id }}">
                     <strong>{{ album.title }}</strong>
                   </router-link><br />
                   {{ album.tracks_count }} tracks
                 </td>
                 <td>
-                  <play-button class="right floated basic icon" :discrete="true" :album="album.id"></play-button>
+                  <play-button class="right floated basic icon" :is-playable="album.is_playable" :discrete="true" :album="album.id"></play-button>
                 </td>
               </tr>
             </tbody>
@@ -41,7 +41,7 @@
           <i class="sound icon"></i>
             <translate :translate-params="{count: artist.albums.length}" :translate-n="artist.albums.length" translate-plural="%{ count } albums">1 album</translate>
         </span>
-        <play-button class="mini basic orange right floated" :artist="artist.id">
+        <play-button :is-playable="isPlayable" class="mini basic orange right floated" :artist="artist.id">
           <translate>Play all</translate>
         </play-button>
       </div>
@@ -70,6 +70,11 @@ export default {
         return this.artist.albums
       }
       return this.artist.albums.slice(0, this.initialAlbums)
+    },
+    isPlayable () {
+      return this.artist.albums.filter((a) => {
+        return a.is_playable
+      }).length > 0
     }
   }
 }
diff --git a/front/src/components/audio/track/Table.vue b/front/src/components/audio/track/Table.vue
index 2b49284c8551ed85f0e40f60091f97c19d78a44a..476a3f0bdbebf131b19158c5f0dd9ed4b4bb2276 100644
--- a/front/src/components/audio/track/Table.vue
+++ b/front/src/components/audio/track/Table.vue
@@ -19,40 +19,6 @@
         :key="index + '-' + track.id"
         v-for="(track, index) in tracks"></track-row>
     </tbody>
-    <tfoot class="full-width">
-      <tr>
-        <th colspan="3">
-          <button @click="showDownloadModal = !showDownloadModal" class="ui basic button">
-             <translate>Download</translate>
-          </button>
-          <modal :show.sync="showDownloadModal">
-            <div class="header"><translate>Download tracks</translate></div>
-            <div class="content">
-              <div class="description">
-                <p><translate>There is currently no way to download directly multiple tracks from funkwhale as a ZIP archive. However, you can use a command line tools such as cURL to easily download a list of tracks.</translate></p>
-                 <translate>Simply copy paste the snippet below into a terminal to launch the download.</translate>
-                <div class="ui warning message">
-                   <translate>Keep your PRIVATE_TOKEN secret as it gives access to your account.</translate>
-                </div>
-                <pre>
-export PRIVATE_TOKEN="{{ $store.state.auth.token }}"
-<template v-for="track in tracks"><template v-if="track.files.length > 0">
-curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ $store.getters['instance/absoluteUrl'](track.files[0].path) }}"</template></template>
-</pre>
-              </div>
-            </div>
-            <div class="actions">
-              <div class="ui black deny button"><translate>Cancel</translate></div>
-            </div>
-          </modal>
-        </th>
-        <th></th>
-        <th colspan="4"></th>
-        <th colspan="6"></th>
-        <th colspan="6"></th>
-        <th></th>
-      </tr>
-    </tfoot>
   </table>
 </template>
 
@@ -74,8 +40,7 @@ export default {
   },
   data () {
     return {
-      backend: backend,
-      showDownloadModal: false
+      backend: backend
     }
   }
 }
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue
index d74b0dfb749274c7a3dcccd43a7480fca89bed84..e8dec339aaa85d85172af1da44c7c11d8b4dc7b0 100644
--- a/front/src/components/common/ActionTable.vue
+++ b/front/src/components/common/ActionTable.vue
@@ -21,13 +21,14 @@
                   :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
                   <translate>Go</translate></div>
                 <dangerous-button
-                  v-else-if="!currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
+                  v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
                   confirm-color="green"
                   color=""
                   @confirm="launchAction">
                   <translate>Go</translate>
                   <p slot="modal-header">
                     <translate
+                      key="1"
                       :translate-n="objectsData.count"
                       :translate-params="{count: objectsData.count, action: currentActionName}"
                       translate-plural="Do you want to launch %{ action } on %{ count } elements?">
@@ -44,6 +45,7 @@
                 <translate
                   tag="span"
                   v-if="selectAll"
+                  key="1"
                   :translate-n="objectsData.count"
                   :translate-params="{count: objectsData.count, total: objectsData.count}"
                   translate-plural="%{ count } on %{ total } selected">
@@ -52,14 +54,16 @@
                 <translate
                   tag="span"
                   v-else
+                  key="2"
                   :translate-n="checked.length"
                   :translate-params="{count: checked.length, total: objectsData.count}"
                   translate-plural="%{ count } on %{ total } selected">
                   %{ count } on %{ total } selected
                 </translate>
-                <template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length">
+                <template v-if="currentAction.allowAll && checkable.length > 0 && checkable.length === checked.length">
                   <a @click="selectAll = true" v-if="!selectAll">
                     <translate
+                      key="3"
                       :translate-n="objectsData.count"
                       :translate-params="{total: objectsData.count}"
                       translate-plural="Select all %{ total } elements">
@@ -67,7 +71,7 @@
                     </translate>
                   </a>
                   <a @click="selectAll = false" v-else>
-                    <translate>Select only current page</translate>
+                    <translate key="4">Select only current page</translate>
                   </a>
                 </template>
               </div>
@@ -108,13 +112,13 @@
       </tr>
     </thead>
     <tbody v-if="objectsData.count > 0">
-      <tr v-for="(obj, index) in objectsData.results">
+      <tr v-for="(obj, index) in objects">
         <td v-if="actions.length > 0" class="collapsing">
           <input
             type="checkbox"
-            :disabled="checkable.indexOf(obj.id) === -1"
-            @click="toggleCheck($event, obj.id, index)"
-            :checked="checked.indexOf(obj.id) > -1"><label>&nbsp;</label>
+            :disabled="checkable.indexOf(getId(obj)) === -1"
+            @click="toggleCheck($event, getId(obj), index)"
+            :checked="checked.indexOf(getId(obj)) > -1"><label>&nbsp;</label>
         </td>
         <slot name="row-cells" :obj="obj"></slot>
       </tr>
@@ -127,9 +131,11 @@ import axios from 'axios'
 export default {
   props: {
     actionUrl: {type: String, required: true},
+    idField: {type: String, required: true, default: 'id'},
     objectsData: {type: Object, required: true},
     actions: {type: Array, required: true, default: () => { return [] }},
-    filters: {type: Object, required: false, default: () => { return {} }}
+    filters: {type: Object, required: false, default: () => { return {} }},
+    customObjects: {type: Array, required: false, default: () => { return [] }},
   },
   components: {},
   data () {
@@ -208,6 +214,9 @@ export default {
         self.actionLoading = false
         self.actionErrors = error.backendErrors
       })
+    },
+    getId (obj) {
+      return obj[this.idField]
     }
   },
   computed: {
@@ -218,6 +227,7 @@ export default {
       })[0]
     },
     checkable () {
+      let self = this
       if (!this.currentAction) {
         return []
       }
@@ -228,7 +238,19 @@ export default {
           return filter(o)
         })
       }
-      return objs.map((o) => { return o.id })
+      return objs.map((o) => { return self.getId(o) })
+    },
+    objects () {
+      let self = this
+      return this.objectsData.results.map((o) => {
+        let custom = self.customObjects.filter((co) => {
+          return self.getId(co) == self.getId(o)
+        })[0]
+        if (custom) {
+          return custom
+        }
+        return o
+      })
     }
   },
   watch: {
@@ -238,6 +260,14 @@ export default {
         this.selectAll = false
       },
       deep: true
+    },
+    currentActionName () {
+      // we update checked status as some actions have specific filters
+      // on what is checkable or not
+      let self = this
+      this.checked = this.checked.filter(r => {
+        return self.checkable.indexOf(r) > -1
+      })
     }
   }
 }
diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e03c199242c5efb4dd8b2663e13786bae4986995
--- /dev/null
+++ b/front/src/components/common/ActorLink.vue
@@ -0,0 +1,30 @@
+<template>
+  <span :title="actor.full_username">
+    <span :style="defaultAvatarStyle" class="ui circular label">{{ actor.preferred_username[0]}}</span>
+    &nbsp;{{ actor.full_username | truncate(30) }}
+  </span>
+</template>
+
+<script>
+import {hashCode, intToRGB} from '@/utils/color'
+
+export default {
+  props: ['actor'],
+  computed: {
+    actorColor () {
+      return intToRGB(hashCode(this.actor.full_username))
+    },
+    defaultAvatarStyle () {
+      return {
+        'background-color': `#${this.actorColor}`
+      }
+    }
+  }
+}
+</script>
+<style scoped>
+.tiny.circular.avatar {
+  width: 1.7em;
+  height: 1.7em;
+}
+</style>
diff --git a/front/src/components/common/CopyInput.vue b/front/src/components/common/CopyInput.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c2db315bd1e11e5b0c0b1910f08d637c4596f481
--- /dev/null
+++ b/front/src/components/common/CopyInput.vue
@@ -0,0 +1,44 @@
+<template>
+  <div class="ui fluid action input">
+    <p class="message" v-if="copied">
+      <translate>Text copied to clipboard!</translate>
+    </p>
+    <input ref="input" :value="value" type="text">
+    <button @click="copy" class="ui teal right labeled icon button">
+      <i class="copy icon"></i>
+      <translate>Copy</translate>
+    </button>
+  </div>
+</template>
+<script>
+export default {
+  props: ['value'],
+  data () {
+    return {
+      copied: false,
+      timeout: null
+    }
+  },
+  methods: {
+    copy () {
+      if (this.timeout) {
+        clearTimeout(this.timeout)
+      }
+      this.$refs.input.select()
+      document.execCommand("Copy")
+      let self = this
+      self.copied = true
+      this.timeout = setTimeout(() => {
+        self.copied = false
+      }, 5000)
+    }
+  }
+}
+</script>
+<style scoped>
+.message {
+  position: absolute;
+  right: 0;
+  bottom: -3em;
+}
+</style>
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
index 6865ac1bc55f74c20914169d48560b96759ec852..ee2deaacb33b23f790dee63e642e7013b1270972 100644
--- a/front/src/components/globals.js
+++ b/front/src/components/globals.js
@@ -12,6 +12,10 @@ import UserLink from '@/components/common/UserLink'
 
 Vue.component('user-link', UserLink)
 
+import ActorLink from '@/components/common/ActorLink'
+
+Vue.component('actor-link', ActorLink)
+
 import Duration from '@/components/common/Duration'
 
 Vue.component('duration', Duration)
@@ -24,4 +28,8 @@ import Message from '@/components/common/Message'
 
 Vue.component('message', Message)
 
+import CopyInput from '@/components/common/CopyInput'
+
+Vue.component('copy-input', CopyInput)
+
 export default {}
diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue
index 1c4849cc31f946c1069a8a48907ed297bec3b164..2ced8c64345840a90c38b0388732bddcd3743366 100644
--- a/front/src/components/library/Artists.vue
+++ b/front/src/components/library/Artists.vue
@@ -143,7 +143,7 @@ export default {
         page_size: this.paginateBy,
         name__icontains: this.query,
         ordering: this.getOrderingAsString(),
-        listenable: 'true'
+        playable: 'true'
       }
       logger.default.debug('Fetching artists')
       axios.get(url, {params: params}).then((response) => {
diff --git a/front/src/components/library/FileUpload.vue b/front/src/components/library/FileUpload.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e703f671cdf5e52e67e71ee57b6396ce8c9bcb2d
--- /dev/null
+++ b/front/src/components/library/FileUpload.vue
@@ -0,0 +1,304 @@
+  <template>
+  <div>
+    <div class="ui hidden clearing divider"></div>
+    <!-- <div v-if="files.length > 0" class="ui indicating progress">
+      <div class="bar"></div>
+      <div class="label">
+        {{ uploadedFilesCount }}/{{ files.length }} files uploaded,
+        {{ processedFilesCount }}/{{ processableFiles }} files processed
+      </div>
+    </div> -->
+    <div class="ui form">
+      <div class="fields">
+        <div class="ui four wide field">
+          <label><translate>Import reference</translate></label>
+          <input type="text" v-model="importReference" />
+        </div>
+      </div>
+
+    </div>
+    <p><translate>This reference will be used to group imported files together.</translate></p>
+    <div class="ui top attached tabular menu">
+      <a :class="['item', {active: currentTab === 'uploads'}]" @click="currentTab = 'uploads'">
+        <translate>Uploading</translate>
+        <div v-if="files.length === 0" class="ui label">
+          0
+        </div>
+        <div v-else-if="files.length > uploadedFilesCount + erroredFilesCount" class="ui yellow label">
+          {{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
+        </div>
+        <div v-else :class="['ui', {'green': erroredFilesCount === 0}, {'red': erroredFilesCount > 0}, 'label']">
+          {{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
+        </div>
+      </a>
+      <a :class="['item', {active: currentTab === 'processing'}]" @click="currentTab = 'processing'">
+        <translate>Processing</translate>
+        <div v-if="processableFiles === 0" class="ui label">
+          0
+        </div>
+        <div v-else-if="processableFiles > processedFilesCount" class="ui yellow label">
+          {{ processedFilesCount }}/{{ processableFiles }}
+        </div>
+        <div v-else :class="['ui', {'green': trackFiles.errored === 0}, {'red': trackFiles.errored > 0}, 'label']">
+          {{ processedFilesCount }}/{{ processableFiles }}
+        </div>
+      </a>
+    </div>
+    <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
+      <div class="ui container">
+        <file-upload-widget
+          :class="['ui', 'icon', 'basic', 'button']"
+          :post-action="uploadUrl"
+          :multiple="true"
+          :data="uploadData"
+          :drop="true"
+          accept="audio/*"
+          v-model="files"
+          name="audio_file"
+          :thread="1"
+          @input-filter="inputFilter"
+          @input-file="inputFile"
+          ref="upload">
+          <i class="upload icon"></i>
+          <translate>Click to select files to upload or drag and drop files or directories</translate>
+        </file-upload-widget>
+      </div>
+
+      <table v-if="files.length > 0" class="ui single line table">
+        <thead>
+          <tr>
+            <th><translate>File name</translate></th>
+            <th><translate>Size</translate></th>
+            <th><translate>Status</translate></th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="(file, index) in sortedFiles" :key="file.id">
+            <td :title="file.name">{{ file.name | truncate(60) }}</td>
+            <td>{{ file.size | humanSize }}</td>
+            <td>
+              <span v-if="file.error" class="ui tooltip" :data-tooltip="labels.tooltips[file.error]">
+                <span class="ui red icon label">
+                  <i class="question circle outline icon" /> {{ file.error }}
+                </span>
+              </span>
+              <span v-else-if="file.success" class="ui green label">
+                <translate key="1">Uploaded</translate>
+              </span>
+              <span v-else-if="file.active" class="ui yellow label">
+                <translate key="2">Uploading...</translate>
+              </span>
+              <template v-else>
+                <span class="ui label"><translate key="3">Pending</translate></span>
+                <button class="ui tiny basic red icon button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button>
+              </template>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+    </div>
+    <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]">
+      <library-files-table
+        :key="String(processTimestamp)"
+        :filters="{import_reference: importReference}"
+        :custom-objects="Object.values(trackFiles.objects)"></library-files-table>
+    </div>
+  </div>
+</template>
+
+<script>
+import $ from 'jquery'
+import axios from 'axios'
+import logger from '@/logging'
+import FileUploadWidget from './FileUploadWidget'
+import LibraryFilesTable from '@/views/content/libraries/FilesTable'
+import moment from 'moment'
+import { WebSocketBridge } from 'django-channels'
+
+export default {
+  props: ['library', 'defaultImportReference'],
+  components: {
+    FileUploadWidget,
+    LibraryFilesTable
+  },
+  data () {
+    let importReference = this.defaultImportReference || moment().format()
+    this.$router.replace({query: {import: importReference}})
+    return {
+      files: [],
+      currentTab: 'uploads',
+      uploadUrl: '/api/v1/track-files/',
+      importReference,
+      trackFiles: {
+        pending: 0,
+        finished: 0,
+        skipped: 0,
+        errored: 0,
+        objects: {},
+      },
+      bridge: null,
+      processTimestamp: new Date()
+    }
+  },
+  created () {
+    this.openWebsocket()
+    this.fetchStatus()
+  },
+  destroyed () {
+    this.disconnect()
+  },
+
+  methods: {
+    inputFilter (newFile, oldFile, prevent) {
+      if (newFile && !oldFile) {
+        let extension = newFile.name.split('.').pop()
+        if (['ogg', 'mp3', 'flac'].indexOf(extension) < 0) {
+          prevent()
+        }
+      }
+    },
+    inputFile (newFile, oldFile) {
+      this.$refs.upload.active = true
+    },
+    fetchStatus () {
+      let self = this
+      let statuses = ['pending', 'errored', 'skipped', 'finished']
+      statuses.forEach((status) => {
+        axios.get('track-files/', {params: {import_reference: self.importReference, import_status: status, page_size: 1}}).then((response) => {
+          self.trackFiles[status] = response.data.count
+        })
+      })
+    },
+    updateProgressBar () {
+      $(this.$el).find('.progress').progress({
+        total: this.files.length * 2,
+        value: this.uploadedFilesCount + this.finishedJobs
+      })
+    },
+    disconnect () {
+      if (!this.bridge) {
+        return
+      }
+      this.bridge.socket.close(1000, 'goodbye', {keepClosed: true})
+    },
+    openWebsocket () {
+      this.disconnect()
+      let self = this
+      let token = this.$store.state.auth.token
+      const bridge = new WebSocketBridge()
+      this.bridge = bridge
+      let url = this.$store.getters['instance/absoluteUrl'](`api/v1/activity?token=${token}`)
+      url = url.replace('http://', 'ws://')
+      url = url.replace('https://', 'wss://')
+      bridge.connect(url)
+      bridge.listen(function (event) {
+        self.handleEvent(event)
+      })
+      bridge.socket.addEventListener('open', function () {
+        console.log('Connected to WebSocket')
+      })
+    },
+    handleEvent (event) {
+      console.log('Received event', event.type, event)
+      let self = this
+      if (event.type === 'import.status_updated') {
+        if (event.track_file.import_reference != self.importReference) {
+          return
+        }
+        this.$nextTick(() => {
+          self.trackFiles[event.old_status] -= 1
+          self.trackFiles[event.new_status] += 1
+          self.trackFiles.objects[event.track_file.uuid] = event.track_file
+          self.triggerReload()
+        })
+      }
+    },
+    triggerReload: _.throttle(function () {
+      this.processTimestamp = new Date()
+    }, 10000, {'leading': true})
+  },
+  computed: {
+    labels () {
+      let denied = this.$gettext('Upload refused, ensure the file is not too big and you have not reached your quota')
+      let server = this.$gettext('Impossible to upload this file, ensure it is not too big')
+      let network = this.$gettext('A network error occured while uploading this file')
+      let timeout = this.$gettext('Upload timeout, please try again')
+      return {
+        tooltips: {
+          denied,
+          server,
+          network,
+          timeout
+        }
+      }
+    },
+    uploadedFilesCount () {
+      return this.files.filter((f) => {
+        return f.success
+      }).length
+    },
+    uploadingFilesCount () {
+      return this.files.filter((f) => {
+        return !f.success && !f.error
+      }).length
+    },
+    erroredFilesCount () {
+      return this.files.filter((f) => {
+        return f.error
+      }).length
+    },
+    processableFiles () {
+      return this.trackFiles.pending + this.trackFiles.skipped + this.trackFiles.errored + this.trackFiles.finished + this.uploadedFilesCount
+    },
+    processedFilesCount () {
+      return this.trackFiles.skipped + this.trackFiles.errored + this.trackFiles.finished
+    },
+    uploadData: function () {
+      return {
+        'library': this.library.uuid,
+        'import_reference': this.importReference,
+      }
+    },
+    sortedFiles () {
+      // return errored files on top
+      return this.files.sort((f) => {
+        if (f.errored) {
+          return -5
+        }
+        if (f.success) {
+          return 5
+        }
+        return 0
+      })
+    }
+  },
+  watch: {
+    uploadedFilesCount () {
+      this.updateProgressBar()
+    },
+    finishedJobs () {
+      this.updateProgressBar()
+    },
+    importReference: _.debounce(function () {
+      this.$router.replace({query: {import: this.importReference}})
+    }, 500)
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+.file-uploads.ui.button {
+  display: block;
+  padding: 2em 1em;
+  width: 100%;
+  box-shadow: none;
+  border-style: dashed !important;
+  border: 3px solid rgba(50, 50, 50, 0.5);
+  font-size: 1.5em;
+}
+.segment.hidden {
+  display: none;
+}
+</style>
diff --git a/front/src/components/library/import/FileUploadWidget.vue b/front/src/components/library/FileUploadWidget.vue
similarity index 85%
rename from front/src/components/library/import/FileUploadWidget.vue
rename to front/src/components/library/FileUploadWidget.vue
index 1de8090c9c91939776818aa22d4e0dd20db9188d..93bead3e7a8bb6b7a263df698761e8d4853a1b72 100644
--- a/front/src/components/library/import/FileUploadWidget.vue
+++ b/front/src/components/library/FileUploadWidget.vue
@@ -19,7 +19,9 @@ export default {
           form.append(key, value)
         }
       }
-      form.append(this.name, file.file, file.file.filename || file.name)
+      let filename = file.file.filename || file.name
+      form.append('source', `upload://${filename}`)
+      form.append(this.name, file.file, filename)
       let xhr = new XMLHttpRequest()
       xhr.open('POST', file.postAction)
       xhr.setRequestHeader('Authorization', this.$store.getters['auth/header'])
diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue
index 0bb16e1dd9e01d316169c45151a45da5a1114cb6..e11127608818e9024ba38f3c85ecef5860a7f7f2 100644
--- a/front/src/components/library/Home.vue
+++ b/front/src/components/library/Home.vue
@@ -13,7 +13,7 @@
           </track-widget>
         </div>
         <div class="column">
-          <playlist-widget :url="'playlists/'" :filters="{scope: 'user', listenable: true, ordering: '-creation_date'}">
+          <playlist-widget :url="'playlists/'" :filters="{scope: 'user', playable: true, ordering: '-creation_date'}">
             <template slot="title"><translate>Playlists</translate></template>
           </playlist-widget>
         </div>
@@ -21,7 +21,7 @@
       <div class="ui section hidden divider"></div>
       <div class="ui grid">
         <div class="ui row">
-          <album-widget :filters="{ordering: '-creation_date'}">
+          <album-widget :filters="{playable: true, ordering: '-creation_date'}">
             <template slot="title"><translate>Recently added</translate></template>
           </album-widget>
         </div>
@@ -72,7 +72,7 @@ export default {
       this.isLoadingArtists = true
       let params = {
         ordering: '-creation_date',
-        listenable: true
+        playable: true
       }
       let url = ARTISTS_URL
       logger.default.time('Loading latest artists')
diff --git a/front/src/components/library/import/ArtistImport.vue b/front/src/components/library/import/ArtistImport.vue
deleted file mode 100644
index f86f71cce1000fb2c998bdbca1969add1426f7f2..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/ArtistImport.vue
+++ /dev/null
@@ -1,170 +0,0 @@
-<template>
-  <div>
-    <h3 class="ui dividing block header">
-      <a :href="getMusicbrainzUrl('artist', metadata.id)" target="_blank" :title="labels.viewOnMusicbrainz">{{ metadata.name }}</a>
-    </h3>
-    <form class="ui form" @submit.prevent="">
-      <h6 class="ui header">
-        <translate>Filter album types</translate>
-      </h6>
-      <div class="inline fields">
-        <div class="field" v-for="t in availableReleaseTypes">
-          <div class="ui checkbox">
-            <input type="checkbox" :value="t" v-model="releaseTypes" />
-            <label>{{ t }}</label>
-          </div>
-        </div>
-        <div class="field">
-          <label><translate>Query template</translate></label>
-          <input v-model="customQueryTemplate" />
-        </div>
-      </div>
-    </form>
-    <template
-      v-for="release in releases">
-      <release-import
-        :key="release.id"
-        :metadata="release"
-        :backends="backends"
-        :defaultEnabled="false"
-        :default-backend-id="defaultBackendId"
-        :query-template="customQueryTemplate"
-        @import-data-changed="recordReleaseData"
-        @enabled="recordReleaseEnabled"
-      ></release-import>
-      <div class="ui divider"></div>
-    </template>
-  </div>
-</template>
-
-<script>
-import Vue from 'vue'
-import axios from 'axios'
-import logger from '@/logging'
-
-import ImportMixin from './ImportMixin'
-import ReleaseImport from './ReleaseImport'
-
-export default Vue.extend({
-  mixins: [ImportMixin],
-  components: {
-    ReleaseImport
-  },
-  data () {
-    return {
-      releaseImportData: [],
-      releaseGroupsData: {},
-      releases: [],
-      releaseTypes: ['Album'],
-      availableReleaseTypes: [
-        'Album',
-        'Live',
-        'Compilation',
-        'EP',
-        'Single',
-        'Other']
-    }
-  },
-  created () {
-    this.fetchReleaseGroupsData()
-  },
-  methods: {
-    recordReleaseData (release) {
-      let existing = this.releaseImportData.filter(r => {
-        return r.releaseId === release.releaseId
-      })[0]
-      if (existing) {
-        existing.tracks = release.tracks
-      } else {
-        this.releaseImportData.push({
-          releaseId: release.releaseId,
-          enabled: true,
-          tracks: release.tracks
-        })
-      }
-    },
-    recordReleaseEnabled (release, enabled) {
-      let existing = this.releaseImportData.filter(r => {
-        return r.releaseId === release.releaseId
-      })[0]
-      if (existing) {
-        existing.enabled = enabled
-      } else {
-        this.releaseImportData.push({
-          releaseId: release.releaseId,
-          enabled: enabled,
-          tracks: release.tracks
-        })
-      }
-    },
-    fetchReleaseGroupsData () {
-      let self = this
-      this.releaseGroups.forEach(group => {
-        let url = 'providers/musicbrainz/releases/browse/' + group.id + '/'
-        return axios.get(url).then((response) => {
-          logger.default.info('successfully fetched release group', group.id)
-          let release = response.data['release-list'].filter(r => {
-            return r.status === 'Official'
-          })[0]
-          self.releaseGroupsData[group.id] = release
-          self.releases = self.computeReleaseData()
-        }, (response) => {
-          logger.default.error('error while fetching release group', group.id)
-        })
-      })
-    },
-    computeReleaseData () {
-      let self = this
-      let releases = []
-      this.releaseGroups.forEach(group => {
-        let data = self.releaseGroupsData[group.id]
-        if (data) {
-          releases.push(data)
-        }
-      })
-      return releases
-    }
-  },
-  computed: {
-    labels () {
-      return {
-        viewOnMusicbrainz: this.$gettext('View on MusicBrainz')
-      }
-    },
-    type () {
-      return 'artist'
-    },
-    releaseGroups () {
-      let self = this
-      return this.metadata['release-group-list'].filter(r => {
-        return self.releaseTypes.indexOf(r.type) !== -1
-      }).sort(function (a, b) {
-        if (a['first-release-date'] < b['first-release-date']) {
-          return -1
-        }
-        if (a['first-release-date'] > b['first-release-date']) {
-          return 1
-        }
-        return 0
-      })
-    },
-    importData () {
-      let releases = this.releaseImportData.filter(r => {
-        return r.enabled
-      })
-      return {
-        artistId: this.metadata.id,
-        count: releases.reduce(function (a, b) {
-          return a + b.tracks.length
-        }, 0),
-        albums: releases
-      }
-    }
-  },
-  watch: {
-    releaseTypes (newValue) {
-      this.fetchReleaseGroupsData()
-    }
-  }
-})
-</script>
diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue
deleted file mode 100644
index fc5801ed18143be8dc911b00f66c7c828e02a18a..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/BatchDetail.vue
+++ /dev/null
@@ -1,275 +0,0 @@
-<template>
-  <div v-title="labels.title">
-    <div v-if="isLoading && !batch" class="ui vertical segment">
-      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-    </div>
-    <div v-if="batch" class="ui vertical stripe segment">
-      <table class="ui very basic table">
-        <tbody>
-          <tr>
-            <td>
-              <strong><translate>Import batch</translate></strong>
-            </td>
-            <td>
-              #{{ batch.id }}
-            </td>
-          </tr>
-          <tr>
-            <td>
-              <strong><translate>Launch date</translate></strong>
-            </td>
-            <td>
-              <human-date :date="batch.creation_date"></human-date>
-            </td>
-          </tr>
-          <tr v-if="batch.user">
-            <td>
-              <strong><translate>Submitted by</translate></strong>
-            </td>
-            <td>
-              <username :username="batch.user.username" />
-            </td>
-          </tr>
-          <tr v-if="stats">
-            <td><strong><translate>Pending</translate></strong></td>
-            <td>{{ stats.pending }}</td>
-          </tr>
-          <tr v-if="stats">
-            <td><strong><translate>Skipped</translate></strong></td>
-            <td>{{ stats.skipped }}</td>
-          </tr>
-          <tr v-if="stats">
-            <td><strong><translate>Errored</translate></strong></td>
-            <td>
-              {{ stats.errored }}
-              <button
-                @click="rerun({batches: [batch.id], jobs: []})"
-                v-if="stats.errored > 0 || stats.pending > 0"
-                class="ui tiny basic icon button">
-                <i class="redo icon" />
-                <translate>Rerun errored jobs</translate>
-              </button>
-            </td>
-          </tr>
-          <tr v-if="stats">
-            <td><strong><translate>Finished</translate></strong></td>
-            <td>{{ stats.finished }}/{{ stats.count}}</td>
-          </tr>
-        </tbody>
-      </table>
-      <div class="ui inline form">
-        <div class="fields">
-          <div class="ui field">
-            <label><translate>Search</translate></label>
-            <input type="text" v-model="jobFilters.search" :placeholder="labels.searchPlaceholder" />
-          </div>
-          <div class="ui field">
-            <label><translate>Status</translate></label>
-            <select class="ui dropdown" v-model="jobFilters.status">
-              <option :value="null"><translate>Any</translate></option>
-              <option :value="'pending'"><translate>Pending</translate></option>
-              <option :value="'errored'"><translate>Errored</translate></option>
-              <option :value="'finished'"><translate>Success</translate></option>
-              <option :value="'skipped'"><translate>Skipped</translate></option>
-            </select>
-          </div>
-        </div>
-      </div>
-      <table v-if="jobResult" class="ui unstackable table">
-        <thead>
-          <tr>
-            <th><translate>Job ID</translate></th>
-            <th><translate>Recording MusicBrainz ID</translate></th>
-            <th><translate>Source</translate></th>
-            <th><translate>Status</translate></th>
-            <th><translate>Track</translate></th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr v-for="job in jobResult.results">
-            <td>{{ job.id }}</th>
-            <td>
-              <a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
-            </td>
-            <td>
-              <a :title="job.source" :href="job.source" target="_blank">
-                {{ job.source|truncate(50) }}
-              </a>
-            </td>
-            <td>
-              <span
-                :class="['ui', {'yellow': job.status === 'pending'}, {'red': job.status === 'errored'}, {'green': job.status === 'finished'}, 'label']">
-                {{ job.status }}</span>
-                <button
-                  @click="rerun({batches: [], jobs: [job.id]})"
-                  v-if="['errored', 'pending'].indexOf(job.status) > -1"
-                  :title="labels.rerun"
-                  class="ui tiny basic icon button">
-                  <i class="redo icon" />
-                </button>
-            </td>
-            <td>
-              <router-link v-if="job.track_file" :to="{name: 'library.tracks.detail', params: {id: job.track_file.track }}">{{ job.track_file.track }}</router-link>
-            </td>
-          </tr>
-        </tbody>
-        <tfoot class="full-width">
-          <tr>
-            <th>
-              <pagination
-              v-if="jobResult && jobResult.count > jobFilters.paginateBy"
-              @page-changed="selectPage"
-              :compact="true"
-              :current="jobFilters.page"
-              :paginate-by="jobFilters.paginateBy"
-              :total="jobResult.count"
-              ></pagination>
-            </th>
-            <th v-if="jobResult && jobResult.results.length > 0">
-              <translate
-                :translate-params="{start: ((jobFilters.page-1) * jobFilters.paginateBy) + 1, end: ((jobFilters.page-1) * jobFilters.paginateBy) + jobResult.results.length, total: jobResult.count}">
-                Showing results %{ start }-%{ end } on %{ total }
-              </translate>
-            <th>
-            <th></th>
-            <th></th>
-            <th></th>
-          </tr>
-        </tfoot>
-      </table>
-    </div>
-  </div>
-</template>
-
-<script>
-import _ from 'lodash'
-import axios from 'axios'
-import logger from '@/logging'
-import Pagination from '@/components/Pagination'
-
-export default {
-  props: ['id'],
-  components: {
-    Pagination
-  },
-  data () {
-    return {
-      isLoading: true,
-      batch: null,
-      stats: null,
-      jobResult: null,
-      timeout: null,
-      jobFilters: {
-        status: null,
-        source: null,
-        search: '',
-        paginateBy: 25,
-        page: 1
-      }
-    }
-  },
-  created () {
-    let self = this
-    this.fetchData().then(() => {
-      self.fetchJobs()
-      self.fetchStats()
-    })
-  },
-  destroyed () {
-    if (this.timeout) {
-      clearTimeout(this.timeout)
-    }
-  },
-  computed: {
-    labels () {
-      let msg = this.$gettext('Import Batch #%{ id }')
-      let title = this.$gettextInterpolate(msg, {id: this.id})
-      let rerun = this.$gettext('Rerun job')
-      let searchPlaceholder = this.$gettext('Search by source...')
-      return {
-        title,
-        searchPlaceholder,
-        rerun
-      }
-    }
-  },
-  methods: {
-    fetchData () {
-      var self = this
-      this.isLoading = true
-      let url = 'import-batches/' + this.id + '/'
-      logger.default.debug('Fetching batch "' + this.id + '"')
-      return axios.get(url).then((response) => {
-        self.batch = response.data
-        self.isLoading = false
-      })
-    },
-    fetchStats () {
-      var self = this
-      let url = 'import-jobs/stats/'
-      axios.get(url, {params: {batch: self.id}}).then((response) => {
-        let old = self.stats
-        self.stats = response.data
-        self.isLoading = false
-        if (!_.isEqual(old, self.stats)) {
-          self.fetchJobs()
-          self.fetchData()
-        }
-        if (self.stats.pending > 0) {
-          self.timeout = setTimeout(
-            self.fetchStats,
-            5000
-          )
-        }
-      })
-    },
-    rerun ({jobs, batches}) {
-      let payload = {
-        jobs, batches
-      }
-      let self = this
-      axios.post('import-jobs/run/', payload).then((response) => {
-        self.fetchStats()
-      })
-    },
-    fetchJobs () {
-      let params = {
-        batch: this.id,
-        page_size: this.jobFilters.paginateBy,
-        page: this.jobFilters.page,
-        q: this.jobFilters.search
-      }
-      if (this.jobFilters.status) {
-        params.status = this.jobFilters.status
-      }
-      if (this.jobFilters.source) {
-        params.source = this.jobFilters.source
-      }
-      let self = this
-      axios.get('import-jobs/', {params}).then((response) => {
-        self.jobResult = response.data
-      })
-    },
-    selectPage: function (page) {
-      this.jobFilters.page = page
-    }
-
-  },
-  watch: {
-    id () {
-      this.fetchData()
-    },
-    jobFilters: {
-      handler () {
-        this.fetchJobs()
-      },
-      deep: true
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-
-</style>
diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue
deleted file mode 100644
index 9ef6bd9cd3a6338c2324d2944da14272ecf6882a..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/BatchList.vue
+++ /dev/null
@@ -1,163 +0,0 @@
-<template>
-  <div v-title="labels.title">
-    <div class="ui vertical stripe segment">
-      <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-      <div class="ui inline form">
-        <div class="fields">
-          <div class="ui field">
-            <label><translate>Search</translate></label>
-            <input type="text" v-model="filters.search" :placeholder="labels.searchPlaceholder" />
-          </div>
-          <div class="ui field">
-            <label><translate>Status</translate></label>
-            <select class="ui dropdown" v-model="filters.status">
-              <option :value="null"><translate>Any</translate></option>
-              <option :value="'pending'"><translate>Pending</translate></option>
-              <option :value="'errored'"><translate>Errored</translate></option>
-              <option :value="'finished'"><translate>Success</translate></option>
-            </select>
-          </div>
-          <div class="ui field">
-            <label><translate>Import source</translate></label>
-            <select class="ui dropdown" v-model="filters.source">
-              <option :value="null"><translate>Any</translate></option>
-              <option :value="'shell'"><translate>CLI</translate></option>
-              <option :value="'api'"><translate>API</translate></option>
-              <option :value="'federation'"><translate>Federation</translate></option>
-            </select>
-          </div>
-        </div>
-      </div>
-      <div class="ui hidden clearing divider"></div>
-      <table v-if="result && result.results.length > 0" class="ui unstackable table">
-        <thead>
-          <tr>
-            <th><translate>ID</translate></th>
-            <th><translate>Launch date</translate></th>
-            <th><translate>Jobs</translate></th>
-            <th><translate>Status</translate></th>
-            <th><translate>Source</translate></th>
-            <th><translate>Submitted by</translate></th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr v-for="obj in result.results">
-            <td>{{ obj.id }}</th>
-            <td>
-              <router-link :to="{name: 'library.import.batches.detail', params: {id: obj.id }}">
-                <human-date :date="obj.creation_date"></human-date>
-              </router-link>
-            </td>
-            <td>{{ obj.job_count }}</td>
-            <td>
-              <span
-                :class="['ui', {'yellow': obj.status === 'pending'}, {'red': obj.status === 'errored'}, {'green': obj.status === 'finished'}, 'label']">{{ obj.status }}
-              </span>
-            </td>
-            <td>{{ obj.source }}</td>
-            <td><template v-if="obj.submitted_by">{{ obj.submitted_by.username }}</template></td>
-          </tr>
-        </tbody>
-        <tfoot class="full-width">
-          <tr>
-            <th>
-              <pagination
-              v-if="result && result.count > filters.paginateBy"
-              @page-changed="selectPage"
-              :compact="true"
-              :current="filters.page"
-              :paginate-by="filters.paginateBy"
-              :total="result.count"
-              ></pagination>
-            </th>
-            <th v-if="result && result.results.length > 0">
-              <translate
-                :translate-params="{start: ((filters.page-1) * filters.paginateBy) + 1, end: ((filters.page-1) * filters.paginateBy) + result.results.length, total: result.count}">
-                Showing results %{ start }-%{ end } on %{ total }
-              </translate>
-            <th>
-            <th></th>
-            <th></th>
-            <th></th>
-          </tr>
-        </tfoot>
-      </table>
-    </div>
-  </div>
-</template>
-
-<script>
-import axios from 'axios'
-import logger from '@/logging'
-import Pagination from '@/components/Pagination'
-
-export default {
-  components: {
-    Pagination
-  },
-  data () {
-    return {
-      result: null,
-      isLoading: false,
-      filters: {
-        status: null,
-        source: null,
-        search: '',
-        paginateBy: 25,
-        page: 1
-      }
-    }
-  },
-  created () {
-    this.fetchData()
-  },
-  computed: {
-    labels () {
-      let searchPlaceholder = this.$gettext('Search by submitter, source...')
-      let title = this.$gettext('Import Batches')
-      return {
-        searchPlaceholder,
-        title
-      }
-    }
-  },
-  methods: {
-    fetchData () {
-      let params = {
-        page_size: this.filters.paginateBy,
-        page: this.filters.page,
-        q: this.filters.search
-      }
-      if (this.filters.status) {
-        params.status = this.filters.status
-      }
-      if (this.filters.source) {
-        params.source = this.filters.source
-      }
-      var self = this
-      this.isLoading = true
-      logger.default.time('Loading import batches')
-      axios.get('import-batches/', {params}).then((response) => {
-        self.result = response.data
-        logger.default.timeEnd('Loading import batches')
-        self.isLoading = false
-      })
-    },
-    selectPage: function (page) {
-      this.filters.page = page
-    }
-  },
-  watch: {
-    filters: {
-      handler () {
-        this.fetchData()
-      },
-      deep: true
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-</style>
diff --git a/front/src/components/library/import/FileUpload.vue b/front/src/components/library/import/FileUpload.vue
deleted file mode 100644
index a9b685523ecac9181ba0260402c0942d739fa80c..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/FileUpload.vue
+++ /dev/null
@@ -1,144 +0,0 @@
-<template>
-  <div>
-    <div v-if="batch" class="ui container">
-      <div class="ui message">
-        <translate>Ensure your music files are properly tagged before uploading them.</translate>
-        <a href="http://picard.musicbrainz.org/" target='_blank'><translate>We recommend using Picard for that purpose.</translate></a>
-      </div>
-      <file-upload-widget
-        :class="['ui', 'icon', 'left', 'floated', 'button']"
-        :post-action="uploadUrl"
-        :multiple="true"
-        :data="uploadData"
-        :drop="true"
-        extensions="ogg,mp3,flac"
-        accept="audio/*"
-        v-model="files"
-        name="audio_file"
-        :thread="1"
-        @input-filter="inputFilter"
-        @input-file="inputFile"
-        ref="upload">
-        <i class="upload icon"></i>
-        <translate>Select files to upload...</translate>
-    </file-upload-widget>
-      <button
-        :class="['ui', 'right', 'floated', 'icon', {disabled: files.length === 0}, 'button']"
-        v-if="!$refs.upload || !$refs.upload.active" @click.prevent="startUpload()">
-        <i class="play icon" aria-hidden="true"></i>
-        <translate>Start Upload</translate>
-      </button>
-      <button type="button" class="ui right floated icon yellow button" v-else @click.prevent="$refs.upload.active = false">
-        <i class="pause icon" aria-hidden="true"></i>
-        <translate>Stop Upload</translate>
-      </button>
-    </div>
-    <div class="ui hidden clearing divider"></div>
-    <template v-if="batch"><translate>Once all your files are uploaded, simply click the following button to check the import status.</translate></template>
-    <router-link class="ui basic button" v-if="batch" :to="{name: 'library.import.batches.detail', params: {id: batch.id }}">
-      <translate>Import detail page</translate>
-    </router-link>
-    <table class="ui single line table">
-      <thead>
-        <tr>
-          <th><translate>File name</translate></th>
-          <th><translate>Size</translate></th>
-          <th><translate>Status</translate></th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr v-for="(file, index) in files" :key="file.id">
-          <td>{{ file.name }}</td>
-          <td>{{ file.size | humanSize }}</td>
-          <td>
-            <span v-if="file.error" class="ui red label">
-              {{ file.error }}
-            </span>
-            <span v-else-if="file.success" class="ui green label"><translate>Success</translate></span>
-            <span v-else-if="file.active" class="ui yellow label"><translate>Uploading...</translate></span>
-            <template v-else>
-              <span class="ui label"><translate>Pending</translate></span>
-              <button class="ui tiny basic red icon button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button>
-            </template>
-          </td>
-        </tr>
-      </tbody>
-    </table>
-  </div>
-</template>
-
-<script>
-import axios from 'axios'
-import logger from '@/logging'
-import FileUploadWidget from './FileUploadWidget'
-
-export default {
-  components: {
-    FileUploadWidget
-  },
-  data () {
-    return {
-      files: [],
-      uploadUrl: '/api/v1/import-jobs/',
-      batch: null
-    }
-  },
-  mounted: function () {
-    this.createBatch()
-  },
-  methods: {
-    inputFilter (newFile, oldFile, prevent) {
-      if (newFile && !oldFile) {
-        let extension = newFile.name.split('.').pop()
-        if (['ogg', 'mp3', 'flac'].indexOf(extension) < 0) {
-          prevent()
-        }
-      }
-    },
-    inputFile (newFile, oldFile) {
-      if (newFile && !oldFile) {
-        // add
-        if (!this.batch) {
-          this.createBatch()
-        }
-      }
-      if (newFile && oldFile) {
-        // update
-      }
-      if (!newFile && oldFile) {
-        // remove
-      }
-    },
-    createBatch () {
-      let self = this
-      return axios.post('import-batches/', {}).then((response) => {
-        self.batch = response.data
-      }, (response) => {
-        logger.default.error('error while launching creating batch')
-      })
-    },
-    startUpload () {
-      this.$emit('batch-created', this.batch)
-      this.$refs.upload.active = true
-    }
-  },
-  computed: {
-    batchId: function () {
-      if (this.batch) {
-        return this.batch.id
-      }
-      return null
-    },
-    uploadData: function () {
-      return {
-        'batch': this.batchId,
-        'source': 'file://'
-      }
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-</style>
diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue
deleted file mode 100644
index 8b0757dcc9256afa7d09fc4e61eb091668ed7582..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/ImportMixin.vue
+++ /dev/null
@@ -1,96 +0,0 @@
-<template>
-
-</template>
-
-<script>
-import axios from 'axios'
-import logger from '@/logging'
-import router from '@/router'
-
-export default {
-  props: {
-    metadata: {type: Object, required: true},
-    defaultEnabled: {type: Boolean, default: true},
-    backends: {type: Array},
-    defaultBackendId: {type: String},
-    queryTemplate: {type: String, default: '$artist $title'},
-    request: {type: Object, required: false}
-  },
-  data () {
-    return {
-      customQueryTemplate: this.queryTemplate,
-      currentBackendId: this.defaultBackendId,
-      isImporting: false,
-      enabled: this.defaultEnabled
-    }
-  },
-  methods: {
-    getMusicbrainzUrl (type, id) {
-      return 'https://musicbrainz.org/' + type + '/' + id
-    },
-    launchImport () {
-      let self = this
-      this.isImporting = true
-      let url = 'submit/' + self.importType + '/'
-      let payload = self.importData
-      if (this.request) {
-        payload.importRequest = this.request.id
-      }
-      axios.post(url, payload).then((response) => {
-        logger.default.info('launched import for', self.type, self.metadata.id)
-        self.isImporting = false
-        router.push({
-          name: 'library.import.batches.detail',
-          params: {
-            id: response.data.id
-          }
-        })
-      }, (response) => {
-        logger.default.error('error while launching import for', self.type, self.metadata.id)
-        self.isImporting = false
-      })
-    }
-  },
-  computed: {
-    importType () {
-      return this.type
-    },
-    currentBackend () {
-      let self = this
-      return this.backends.filter(b => {
-        return b.id === self.currentBackendId
-      })[0]
-    },
-    realQueryTemplate () {
-
-    }
-  },
-  watch: {
-    isImporting (newValue) {
-      this.$emit('import-state-changed', newValue)
-    },
-    importData: {
-      handler (newValue) {
-        this.$emit('import-data-changed', newValue)
-      },
-      deep: true
-    },
-    enabled (newValue) {
-      this.$emit('enabled', this.importData, newValue)
-    },
-    queryTemplate (newValue, oldValue) {
-      // we inherit from the prop template unless the component changed
-      // the value
-      if (oldValue === this.customQueryTemplate) {
-        // no changed from prop, we keep the sync
-        this.customQueryTemplate = newValue
-      }
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-
-</style>
diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue
deleted file mode 100644
index 08047fd2fc1b09f020c6b8680f24548a5f0ce964..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/Main.vue
+++ /dev/null
@@ -1,306 +0,0 @@
-<template>
-  <div v-title="labels.title">
-    <div class="ui vertical stripe segment">
-      <div class="ui top three attached ordered steps">
-        <a @click="currentStep = 0" :class="['step', {'active': currentStep === 0}, {'completed': currentStep > 0}]">
-          <div class="content">
-            <div class="title"><translate>Import source</translate></div>
-            <div class="description"><translate>Uploaded files or external source</translate></div>
-          </div>
-        </a>
-        <a @click="currentStep = 1" :class="['step', {'active': currentStep === 1}, {'completed': currentStep > 1}]">
-          <div class="content">
-            <div class="title"><translate>Metadata</translate></div>
-            <div class="description"><translate>Grab corresponding metadata</translate></div>
-          </div>
-        </a>
-        <a @click="currentStep = 2" :class="['step', {'active': currentStep === 2}, {'completed': currentStep > 2}]">
-          <div class="content">
-            <div class="title"><translate>Music</translate></div>
-            <div class="description"><translate>Select relevant sources or files for import</translate></div>
-          </div>
-        </a>
-      </div>
-      <div class="ui hidden divider"></div>
-      <div class="ui centered buttons">
-        <button @click="currentStep -= 1" :disabled="currentStep === 0" class="ui icon button"><i class="left arrow icon"></i>
-          <translate>Previous step</translate>
-        </button>
-        <button @click="nextStep()" v-if="currentStep < 2" class="ui icon button">
-          <translate>Next step</translate>
-          <i class="right arrow icon"></i>
-        </button>
-        <button
-          @click="$refs.import.launchImport()"
-          v-if="currentStep === 2 && currentSource != 'upload'"
-          :class="['ui', 'positive', 'icon', {'loading': isImporting}, 'button']"
-          :disabled="isImporting || importData.count === 0"
-          >
-            <translate
-              :translate-params="{count: importData.count || 0}"
-              :translate-n="importData.count || 0"
-              translate-plural="Import %{ count } tracks">
-              Import %{ count } track
-            </translate>
-            <i class="check icon"></i>
-          </button>
-        <button
-          v-else-if="currentStep === 2 && currentSource === 'upload'"
-          @click="$router.push({name: 'library.import.batches.detail', params: {id: importBatch.id}})"
-          :class="['ui', 'positive', 'icon', {'disabled': !importBatch}, 'button']"
-          :disabled="!importBatch"
-          >
-            <translate>Finish import</translate>
-            <i class="check icon"></i>
-          </button>
-      </div>
-      <div class="ui hidden divider"></div>
-      <div class="ui attached segment">
-        <template v-if="currentStep === 0">
-          <p><translate>First, choose where you want to import the music from</translate></p>
-          <form class="ui form">
-            <div class="field">
-              <div class="ui radio checkbox">
-                <input type="radio" id="external" value="external" v-model="currentSource">
-                <label for="external">
-                  <translate>External source. Supported backends</translate>
-                  <div v-for="backend in backends" class="ui basic label">
-                    <i v-if="backend.icon" :class="[backend.icon, 'icon']"></i>
-                    {{ backend.label }}
-                  </div>
-                </label>
-              </div>
-            </div>
-            <div class="field">
-              <div class="ui radio checkbox">
-                <input type="radio" id="upload" value="upload" v-model="currentSource">
-                <label for="upload"><translate>File upload</translate></label>
-              </div>
-            </div>
-          </form>
-        </template>
-        <div v-if="currentStep === 1" class="ui stackable two column grid">
-          <div class="column">
-            <form class="ui form" @submit.prevent="">
-              <div class="field">
-                <label><translate>Search an entity you want to import:</translate></label>
-                <metadata-search
-                  :mb-type="mbType"
-                  :mb-id="mbId"
-                  @id-changed="updateId"
-                  @type-changed="updateType"></metadata-search>
-              </div>
-            </form>
-            <div class="ui horizontal divider"><translate>Or</translate></div>
-            <form class="ui form" @submit.prevent="">
-              <div class="field">
-                <label><translate>Input a MusicBrainz ID manually:</translate></label>
-                <input type="text" v-model="currentId" />
-              </div>
-            </form>
-            <div class="ui hidden divider"></div>
-            <template v-if="currentType && currentId">
-              <h4 class="ui header">
-                <translate>You will import:</translate>
-              </h4>
-              <component
-                :mbId="currentId"
-                :is="metadataComponent"
-                @metadata-changed="this.updateMetadata"
-                ></component>
-            </template>
-            <p><translate>You can also skip this step and enter metadata manually.</translate></p>
-          </div>
-          <div class="column">
-            <h5 class="ui header"><translate>What is metadata?</translate></h5>
-            <template v-translate>
-              Metadata is the data related to the music you want to import. This includes all the information about the artists, albums and tracks. In order to have a high quality library, it is recommended to grab data from the
-              <a href="https://musicbrainz.org" target="_blank">
-                MusicBrainz
-              </a>
-              project, which you can think about as the Wikipedia of music.
-            </template>
-          </div>
-        </div>
-        <div v-if="currentStep === 2">
-          <file-upload
-            ref="import"
-            @batch-created="updateBatch"
-            v-if="currentSource == 'upload'"
-            ></file-upload>
-
-          <component
-            ref="import"
-            v-if="currentSource == 'external'"
-            :request="currentRequest"
-            :metadata="metadata"
-            :is="importComponent"
-            :backends="backends"
-            :default-backend-id="backends[0].id"
-            @import-data-changed="updateImportData"
-            @import-state-changed="updateImportState"
-            ></component>
-        </div>
-      </div>
-    </div>
-    <div class="ui vertical stripe segment" v-if="currentRequest">
-      <h3 class="ui header">
-        <translate>Music request</translate>
-      </h3>
-      <p><translate>This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.</translate></p>
-      <request-card :request="currentRequest" :import-action="false"></request-card>
-
-    </div>
-  </div>
-</template>
-
-<script>
-
-import RequestCard from '@/components/requests/Card'
-import MetadataSearch from '@/components/metadata/Search'
-import ReleaseCard from '@/components/metadata/ReleaseCard'
-import ArtistCard from '@/components/metadata/ArtistCard'
-import ReleaseImport from './ReleaseImport'
-import FileUpload from './FileUpload'
-import ArtistImport from './ArtistImport'
-
-import axios from 'axios'
-import router from '@/router'
-import $ from 'jquery'
-
-export default {
-  components: {
-    MetadataSearch,
-    ArtistCard,
-    ReleaseCard,
-    ArtistImport,
-    ReleaseImport,
-    FileUpload,
-    RequestCard
-  },
-  props: {
-    mbType: {type: String, required: false},
-    request: {type: String, required: false},
-    source: {type: String, required: false},
-    mbId: {type: String, required: false}
-  },
-  data: function () {
-    return {
-      currentRequest: null,
-      currentType: this.mbType || 'artist',
-      currentId: this.mbId,
-      currentStep: 0,
-      currentSource: this.source,
-      metadata: {},
-      isImporting: false,
-      importBatch: null,
-      importData: {
-        tracks: []
-      },
-      backends: [
-        {
-          id: 'youtube',
-          label: 'YouTube',
-          icon: 'youtube'
-        }
-      ]
-    }
-  },
-  created () {
-    if (this.request) {
-      this.fetchRequest(this.request)
-    }
-    if (this.currentSource) {
-      this.currentStep = 1
-    }
-  },
-  mounted: function () {
-    $(this.$el).find('.ui.checkbox').checkbox()
-  },
-  methods: {
-    updateRoute () {
-      router.replace({
-        query: {
-          source: this.currentSource,
-          type: this.currentType,
-          id: this.currentId,
-          request: this.request
-        }
-      })
-    },
-    updateImportData (newValue) {
-      this.importData = newValue
-    },
-    updateImportState (newValue) {
-      this.isImporting = newValue
-    },
-    updateMetadata (newValue) {
-      this.metadata = newValue
-    },
-    updateType (newValue) {
-      this.currentType = newValue
-    },
-    updateId (newValue) {
-      this.currentId = newValue
-    },
-    updateBatch (batch) {
-      this.importBatch = batch
-    },
-    fetchRequest (id) {
-      let self = this
-      axios.get(`requests/import-requests/${id}`).then((response) => {
-        self.currentRequest = response.data
-      })
-    },
-    nextStep () {
-      if (this.currentStep === 0 && this.currentSource === 'upload') {
-        // we skip metadata directly
-        this.currentStep += 2
-      } else {
-        this.currentStep += 1
-      }
-    }
-  },
-  computed: {
-    labels () {
-      return {
-        title: this.$gettext('Import Music')
-      }
-    },
-    metadataComponent () {
-      if (this.currentType === 'artist') {
-        return 'ArtistCard'
-      }
-      if (this.currentType === 'release') {
-        return 'ReleaseCard'
-      }
-      if (this.currentType === 'recording') {
-        return 'RecordingCard'
-      }
-    },
-    importComponent () {
-      if (this.currentType === 'artist') {
-        return 'ArtistImport'
-      }
-      if (this.currentType === 'release') {
-        return 'ReleaseImport'
-      }
-      if (this.currentType === 'recording') {
-        return 'RecordingImport'
-      }
-    }
-  },
-  watch: {
-    currentType (newValue) {
-      this.currentId = ''
-      this.updateRoute()
-    },
-    currentId (newValue) {
-      this.updateRoute()
-    }
-  }
-}
-</script>
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-</style>
diff --git a/front/src/components/library/import/ReleaseImport.vue b/front/src/components/library/import/ReleaseImport.vue
deleted file mode 100644
index 0ec78903071224dbdbc613594228801a5c297a7f..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/ReleaseImport.vue
+++ /dev/null
@@ -1,119 +0,0 @@
-<template>
-  <div>
-    <h3 class="ui dividing block header">
-      <translate
-        tag="div"
-        translate-plural="Album %{ title } (%{ count } tracks) by %{ artist }"
-        :translate-n="tracks.length"
-        :translate-params="{count: tracks.length, title: metadata.title, artist: metadata['artist-credit-phrase']}">
-        Album %{ title } (%{ count } track) by %{ artist }
-      </translate>
-      <div class="ui divider"></div>
-      <div class="sub header">
-        <div class="ui toggle checkbox">
-          <input type="checkbox" v-model="enabled" />
-          <label><translate>Import this release</translate></label>
-        </div>
-      </div>
-    </h3>
-    <template
-      v-if="enabled"
-      v-for="track in tracks">
-      <track-import
-        :key="track.recording.id"
-        :metadata="track"
-        :release-metadata="metadata"
-        :backends="backends"
-        :default-backend-id="defaultBackendId"
-        :query-template="customQueryTemplate"
-        @import-data-changed="recordTrackData"
-        @enabled="recordTrackEnabled"
-      ></track-import>
-      <div class="ui divider"></div>
-    </template>
-  </div>
-</template>
-
-<script>
-import Vue from 'vue'
-import ImportMixin from './ImportMixin'
-import TrackImport from './TrackImport'
-
-export default Vue.extend({
-  mixins: [ImportMixin],
-  components: {
-    TrackImport
-  },
-  data () {
-    return {
-      trackImportData: []
-    }
-  },
-  methods: {
-    recordTrackData (track) {
-      let existing = this.trackImportData.filter(t => {
-        return t.mbid === track.mbid
-      })[0]
-      if (existing) {
-        existing.source = track.source
-      } else {
-        this.trackImportData.push({
-          mbid: track.mbid,
-          enabled: true,
-          source: track.source
-        })
-      }
-    },
-    recordTrackEnabled (track, enabled) {
-      let existing = this.trackImportData.filter(t => {
-        return t.mbid === track.mbid
-      })[0]
-      if (existing) {
-        existing.enabled = enabled
-      } else {
-        this.trackImportData.push({
-          mbid: track.mbid,
-          enabled: enabled,
-          source: null
-        })
-      }
-    }
-  },
-  computed: {
-    type () {
-      return 'release'
-    },
-    importType () {
-      return 'album'
-    },
-    tracks () {
-      return this.metadata['medium-list'][0]['track-list']
-    },
-    importData () {
-      let tracks = this.trackImportData.filter(t => {
-        return t.enabled
-      })
-      return {
-        releaseId: this.metadata.id,
-        count: tracks.length,
-        tracks: tracks
-      }
-    }
-  },
-  watch: {
-    importData: {
-      handler (newValue) {
-        this.$emit('import-data-changed', newValue)
-      },
-      deep: true
-    }
-  }
-})
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-.ui.card {
-    width: 100% !important;
-}
-</style>
diff --git a/front/src/components/library/import/TrackImport.vue b/front/src/components/library/import/TrackImport.vue
deleted file mode 100644
index 10a146344489bb54b6c73a30b7c24ac6d5d76434..0000000000000000000000000000000000000000
--- a/front/src/components/library/import/TrackImport.vue
+++ /dev/null
@@ -1,206 +0,0 @@
-<template>
-  <div class="ui stackable grid">
-    <div class="three wide column">
-      <h5 class="ui header">
-        {{ metadata.position }}. {{ metadata.recording.title }}
-        <div class="sub header">
-          {{ time.parse(parseInt(metadata.length) / 1000) }}
-        </div>
-      </h5>
-      <div class="ui toggle checkbox">
-        <input type="checkbox" v-model="enabled" />
-        <label><translate>Import this track</translate></label>
-      </div>
-    </div>
-    <div class="three wide column" v-if="enabled">
-      <form class="ui mini form" @submit.prevent="">
-        <div class="field">
-          <label><translate>Source</translate></label>
-          <select v-model="currentBackendId">
-            <option v-for="backend in backends" :value="backend.id">
-              {{ backend.label }}
-            </option>
-          </select>
-        </div>
-      </form>
-      <div class="ui hidden divider"></div>
-      <template v-if="currentResult">
-        <button @click="currentResultIndex -= 1" class="ui basic tiny icon button" :disabled="currentResultIndex === 0">
-          <i class="left arrow icon"></i>
-        </button>
-        {{ results.total }}
-        <translate :translate-params="{current: currentResultIndex + 1, total: results.length}">
-          Result %{ current }/%{ total }
-        </translate>
-        <button @click="currentResultIndex += 1" class="ui basic tiny icon button" :disabled="currentResultIndex + 1 === results.length">
-          <i class="right arrow icon"></i>
-        </button>
-      </template>
-    </div>
-    <div class="four wide column" v-if="enabled">
-      <form class="ui mini form" @submit.prevent="">
-        <div class="field">
-          <label><translate>Search query</translate></label>
-          <input type="text" v-model="query" />
-          <label><translate>Imported URL</translate></label>
-          <input type="text" v-model="importedUrl" />
-        </div>
-      </form>
-    </div>
-    <div class="six wide column" v-if="enabled">
-      <div v-if="isLoading" class="ui vertical segment">
-        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-      </div>
-      <div v-if="!isLoading && currentResult" class="ui items">
-        <div class="item">
-          <div class="ui small image">
-            <img :src="currentResult.cover" />
-          </div>
-          <div class="content">
-            <a
-              :href="currentResult.url"
-              target="_blank"
-              class="description"
-              v-html="$options.filters.highlight(currentResult.title, warnings)"></a>
-            <div v-if="currentResult.channelTitle" class="meta">
-              {{ currentResult.channelTitle}}
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script>
-import axios from 'axios'
-import Vue from 'vue'
-import time from '@/utils/time'
-import logger from '@/logging'
-import ImportMixin from './ImportMixin'
-
-import $ from 'jquery'
-
-Vue.filter('highlight', function (words, query) {
-  query.forEach(w => {
-    let re = new RegExp('(' + w + ')', 'gi')
-    words = words.replace(re, '<span class=\'highlight\'>$1</span>')
-  })
-  return words
-})
-
-export default Vue.extend({
-  mixins: [ImportMixin],
-  props: {
-    releaseMetadata: {type: Object, required: true}
-  },
-  data () {
-    return {
-      isLoading: false,
-      results: [],
-      currentResultIndex: 0,
-      importedUrl: '',
-      warnings: [
-        'live',
-        'tv',
-        'full',
-        'cover',
-        'mix'
-      ],
-      customQuery: '',
-      time
-    }
-  },
-  created () {
-    if (this.enabled) {
-      this.search()
-    }
-  },
-  mounted () {
-    $('.ui.checkbox').checkbox()
-  },
-  methods: {
-    search: function () {
-      let self = this
-      this.isLoading = true
-      let url = 'providers/' + this.currentBackendId + '/search/'
-      axios.get(url, {params: {query: this.query}}).then((response) => {
-        logger.default.debug('searching', self.query, 'on', self.currentBackendId)
-        self.results = response.data
-        self.isLoading = false
-      }, (response) => {
-        logger.default.error('error while searching', self.query, 'on', self.currentBackendId)
-        self.isLoading = false
-      })
-    }
-  },
-  computed: {
-    type () {
-      return 'track'
-    },
-    currentResult () {
-      if (this.results) {
-        return this.results[this.currentResultIndex]
-      }
-    },
-    importData () {
-      return {
-        count: 1,
-        mbid: this.metadata.recording.id,
-        source: this.importedUrl
-      }
-    },
-    query: {
-      get: function () {
-        if (this.customQuery.length > 0) {
-          return this.customQuery
-        }
-        let queryMapping = [
-          ['artist', this.releaseMetadata['artist-credit'][0]['artist']['name']],
-          ['album', this.releaseMetadata['title']],
-          ['title', this.metadata['recording']['title']]
-        ]
-        let query = this.customQueryTemplate
-        queryMapping.forEach(e => {
-          query = query.split('$' + e[0]).join(e[1])
-        })
-        return query
-      },
-      set: function (newValue) {
-        this.customQuery = newValue
-      }
-    }
-  },
-  watch: {
-    query () {
-      this.search()
-    },
-    currentResult (newValue) {
-      if (newValue) {
-        this.importedUrl = newValue.url
-      }
-    },
-    importedUrl (newValue) {
-      this.$emit('url-changed', this.importData, this.importedUrl)
-    },
-    enabled (newValue) {
-      if (newValue && this.results.length === 0) {
-        this.search()
-      }
-    }
-  }
-})
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-.ui.card {
-    width: 100% !important;
-}
-</style>
-<style lang="scss">
-.highlight {
-  font-weight: bold !important;
-  background-color: yellow !important;
-}
-</style>
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 0d8a0b9367c126d8585133a78bd56466814b5544..ad87ef54e154962c279e8d900683dfcb6e68dda1 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -18,12 +18,9 @@ import LibraryArtist from '@/components/library/Artist'
 import LibraryArtists from '@/components/library/Artists'
 import LibraryAlbum from '@/components/library/Album'
 import LibraryTrack from '@/components/library/Track'
-import LibraryImport from '@/components/library/import/Main'
 import LibraryRadios from '@/components/library/Radios'
 import RadioBuilder from '@/components/library/radios/Builder'
 import RadioDetail from '@/views/radios/Detail'
-import BatchList from '@/components/library/import/BatchList'
-import BatchDetail from '@/components/library/import/BatchDetail'
 import PlaylistDetail from '@/views/playlists/Detail'
 import PlaylistList from '@/views/playlists/List'
 import Favorites from '@/components/favorites/List'
@@ -42,6 +39,13 @@ import FederationLibraryDetail from '@/views/federation/LibraryDetail'
 import FederationLibraryList from '@/views/federation/LibraryList'
 import FederationTrackList from '@/views/federation/LibraryTrackList'
 import FederationFollowersList from '@/views/federation/LibraryFollowersList'
+import ContentBase from '@/views/content/Base'
+import ContentHome from '@/views/content/Home'
+import LibrariesHome from '@/views/content/libraries/Home'
+import LibrariesUpload from '@/views/content/libraries/Upload'
+import LibrariesDetail from '@/views/content/libraries/Detail'
+import LibrariesFiles from '@/views/content/libraries/Files'
+import RemoteLibrariesHome from '@/views/content/remote/Home'
 
 Vue.use(Router)
 
@@ -128,6 +132,68 @@ export default new Router({
         defaultPaginateBy: route.query.paginateBy
       })
     },
+    {
+      path: '/content',
+      component: ContentBase,
+      children: [
+        {
+          path: '',
+          name: 'content.index',
+          component: ContentHome
+        }
+      ]
+    },
+    {
+      path: '/content/libraries/tracks',
+      component: ContentBase,
+      children: [
+        {
+          path: '',
+          name: 'content.libraries.files',
+          component: LibrariesFiles,
+          props: (route) => ({
+            query: route.query.q
+          })
+        }
+      ]
+    },
+    {
+      path: '/content/libraries',
+      component: ContentBase,
+      children: [
+        {
+          path: '',
+          name: 'content.libraries.index',
+          component: LibrariesHome
+        },
+        {
+          path: ':id/upload',
+          name: 'content.libraries.detail.upload',
+          component: LibrariesUpload,
+          props: (route) => ({
+            id: route.params.id,
+            defaultImportReference: route.query.import
+          })
+        },
+        {
+          path: ':id',
+          name: 'content.libraries.detail',
+          component: LibrariesDetail,
+          props: true
+        }
+      ]
+    },
+    {
+      path: '/content/remote',
+      component: ContentBase,
+      children: [
+        {
+          path: '',
+          name: 'content.remote.index',
+          component: RemoteLibrariesHome
+        }
+      ]
+    },
     {
       path: '/manage/settings',
       name: 'manage.settings',
@@ -272,24 +338,6 @@ export default new Router({
         { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
         { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
         { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
-        {
-          path: 'import/launch',
-          name: 'library.import.launch',
-          component: LibraryImport,
-          props: (route) => ({
-            source: route.query.source,
-            request: route.query.request,
-            mbType: route.query.type,
-            mbId: route.query.id })
-        },
-        {
-          path: 'import/batches',
-          name: 'library.import.batches',
-          component: BatchList,
-          children: [
-          ]
-        },
-        { path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
       ]
     },
     { path: '*', component: PageNotFound }
diff --git a/front/src/search.js b/front/src/search.js
new file mode 100644
index 0000000000000000000000000000000000000000..5fc2a6b94401d70b2736e4512c26c12c57246cca
--- /dev/null
+++ b/front/src/search.js
@@ -0,0 +1,69 @@
+export function normalizeQuery (query) {
+	// given a string such as 'this is "my query" go', returns
+	// an array of tokens like this: ['this', 'is', 'my query', 'go']
+	if (!query) {
+		return []
+	}
+	return query.match(/\\?.|^$/g).reduce((p, c) => {
+		if (c === '"'){
+			p.quote ^= 1
+		} else if (!p.quote && c === ' '){
+			p.a.push('')
+		} else {
+			p.a[p.a.length-1] += c.replace(/\\(.)/,"$1")
+		}
+		return p
+	}, {a: ['']}).a
+}
+
+export function parseTokens (tokens) {
+	// given an array of tokens as returned by normalizeQuery,
+	// returns a list of objects such as [
+	// 	{
+	// 		field: 'status',
+	// 		value: 'pending'
+	// 	},
+	// 	{
+	// 		field: null,
+	// 		value: 'hello'
+	// 	}
+	// ]
+	return tokens.map(t => {
+		// we split the token on ":"
+		let parts = t.split(/:(.+)/)
+		if (parts.length === 1) {
+			// no field specified
+			return {field: null, value: t}
+		}
+		// first item is the field, second is the value, possibly quoted
+		let field = parts[0]
+		let rawValue = parts[1]
+
+		// we remove surrounding quotes if any
+		if (rawValue[0] === '"') {
+			rawValue = rawValue.substring(1)
+		}
+		if (rawValue.slice(-1) === '"') {
+			rawValue = rawValue.substring(0, rawValue.length - 1);
+		}
+		return {field, value: rawValue}
+	})
+}
+
+export function compileTokens (tokens) {
+  // given a list of tokens as returned by parseTokens,
+  // returns a string query
+  let parts = tokens.map(t => {
+    let v = t.value
+    let k = t.field
+    if (v.indexOf(' ') > -1) {
+      v = `"${v}"`
+    }
+    if (k) {
+      return `${k}:${v}`
+    } else {
+      return v
+    }
+  })
+  return parts.join(' ')
+}
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index 163c595e302a0997887862f913207b01be0bb3b2..a1a1530a9f31029acd526bb21f47519194e0d78a 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -31,6 +31,9 @@ export default {
       users: {
         registration_enabled: {
           value: true
+        },
+        upload_quota: {
+          value: 0
         }
       },
       subsonic: {
diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue
index d21b4e277c307f911245a0a29937cb929f4f46f3..ac3c3408040665a2610eb9b92e1d65f8275585c7 100644
--- a/front/src/views/admin/Settings.vue
+++ b/front/src/views/admin/Settings.vue
@@ -101,7 +101,8 @@ export default {
           settings: [
             'users__registration_enabled',
             'common__api_authentication_required',
-            'users__default_permissions'
+            'users__default_permissions',
+            'users__upload_quota'
           ]
         },
         {
diff --git a/front/src/views/content/Base.vue b/front/src/views/content/Base.vue
new file mode 100644
index 0000000000000000000000000000000000000000..cfdd204281bb2086700e1abf744fda1121a8ad51
--- /dev/null
+++ b/front/src/views/content/Base.vue
@@ -0,0 +1,26 @@
+<template>
+  <div class="main pusher"  v-title="labels.title">
+    <div class="ui secondary pointing menu">
+      <router-link
+        class="ui item"
+        :to="{name: 'content.libraries.index'}"><translate>Libraries</translate></router-link>
+      <router-link
+        class="ui item"
+        :to="{name: 'content.libraries.files'}"><translate>Tracks</translate></router-link>
+    </div>
+    <router-view :key="$route.fullPath"></router-view>
+  </div>
+</template>
+<script>
+
+export default {
+  computed: {
+    labels () {
+      let title = this.$gettext('Add content')
+      return {
+        title
+      }
+    }
+  }
+}
+</script>
diff --git a/front/src/views/content/Home.vue b/front/src/views/content/Home.vue
new file mode 100644
index 0000000000000000000000000000000000000000..91fe11824d694ff613c99065213e8b4916e975a0
--- /dev/null
+++ b/front/src/views/content/Home.vue
@@ -0,0 +1,44 @@
+<template>
+  <div class="ui vertical aligned stripe segment" v-title="labels.title">
+    <div class="ui text container">
+      <h1>{{ labels.title }}</h1>
+      <p><translate>We offer various way to grab new content and make it available here.</translate></p>
+      <div class="ui segment">
+        <h2><translate>Upload audio content</translate></h2>
+        <p><translate>Upload music files (mp3, ogg, flac, etc.) from your personal library directly from your browser to enjoy them here.</translate></p>
+        <p>
+          <strong><translate :translate-params="{quota: defaultQuota}">This instance offers up to %{quota} of storage space to every user.</translate></strong>
+        </p>
+        <router-link :to="{name: 'content.libraries.index'}" class="ui green button">
+          <translate>Get started</translate>
+        </router-link>
+      </div>
+      <div class="ui segment">
+        <h2><translate>Follow remote libraries</translate></h2>
+        <p><translate>You can follow libraries from other users to get access to new music. Public libraries can be followed immediatly, while following a private library requires approval from its owner.</translate></p>
+        <router-link :to="{name: 'content.remote.index'}" class="ui green button">
+          <translate>Get started</translate>
+        </router-link>
+      </div>
+
+    </div>
+  </div>
+</template>
+
+<script>
+import {humanSize} from '@/filters'
+
+export default {
+  computed: {
+    labels () {
+      return {
+        title: this.$gettext('Add and manage content')
+      }
+    },
+    defaultQuota () {
+      let quota = this.$store.state.instance.settings.users.upload_quota.value * 1000 * 1000
+      return humanSize(quota)
+    }
+  }
+}
+</script>
diff --git a/front/src/views/content/libraries/Card.vue b/front/src/views/content/libraries/Card.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bcbf77c17c20aca0c6ba3596bf2f3b603f4fd034
--- /dev/null
+++ b/front/src/views/content/libraries/Card.vue
@@ -0,0 +1,74 @@
+<template>
+  <div class="ui fluid card">
+    <div class="content">
+      <div class="header">
+        {{ library.name }}
+        <span
+          v-if="library.privacy_level === 'me'"
+          class="right floated"
+          :data-tooltip="labels.tooltips.me">
+          <i class="small lock icon"></i>
+        </span>
+        <span
+          v-else-if="library.privacy_level === 'instance'"
+          class="right floated"
+          :data-tooltip="labels.tooltips.instance">
+          <i class="small circle outline icon"></i>
+        </span>
+        <span
+          v-else-if="library.privacy_level === 'everyone'"
+          class="right floated"
+          :data-tooltip="labels.tooltips.everyone">
+          <i class="small globe icon"></i>
+        </span>
+      </div>
+      <div class="meta">
+        <span>
+          <i class="small outline clock icon" />
+          <human-date :date="library.creation_date" />
+        </span>
+      </div>
+      <div class="description">
+        <div class="ui hidden divider"></div>
+      </div>
+      <div class="content">
+        <span v-if="library.size" class="right floated" :data-tooltip="labels.tooltips.size">
+          <i class="database icon"></i>
+          {{ library.size | humanSize }}
+        </span>
+        <i class="music icon"></i>
+        <translate :translate-params="{count: library.files_count}" :translate-n="library.files_count" translate-plural="%{ count } tracks">1 tracks</translate>
+      </div>
+    </div>
+    <div class="ui bottom basic attached buttons">
+      <router-link :to="{name: 'content.libraries.detail.upload', params: {id: library.uuid}}" class="ui button">
+        <translate>Upload</translate>
+      </router-link>
+      <router-link :to="{name: 'content.libraries.detail', params: {id: library.uuid}}" exact class="ui button">
+        <translate>Detail</translate>
+      </router-link>
+    </div>
+  </div>
+</template>
+<script>
+export default {
+  props: ['library'],
+  computed: {
+    labels () {
+      let me = this.$gettext('Visibility: nobody except me')
+      let instance = this.$gettext('Visibility: everyone on this instance')
+      let everyone = this.$gettext('Visibility: everyone, including other instances')
+      let size = this.$gettext('Total size of the files in this library')
+
+      return {
+        tooltips: {
+          me,
+          instance,
+          everyone,
+          size
+        }
+      }
+    }
+  }
+}
+</script>
diff --git a/front/src/views/content/libraries/Detail.vue b/front/src/views/content/libraries/Detail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..661ac92e612788e4cec87c0789898187cc444d9e
--- /dev/null
+++ b/front/src/views/content/libraries/Detail.vue
@@ -0,0 +1,59 @@
+<template>
+  <div class="ui vertical aligned stripe segment">
+    <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']">
+      <div class="ui text loader"><translate>Loading library data...</translate></div>
+    </div>
+    <detail-area v-else :library="library">
+      <div slot="header">
+        <h2 class="ui header"><translate>Manage</translate></h2>
+        <p><a @click="hiddenForm = !hiddenForm">
+          <i class="pencil icon" />
+          <translate>Edit library</translate>
+        </a></p>
+        <library-form v-if="!hiddenForm" :library="library" @updated="libraryUpdated" @deleted="libraryDeleted" />
+        <div class="ui hidden divider"></div>
+        <div class="ui form">
+          <div class="field">
+            <label><translate>Sharing link</translate></label>
+            <p><translate>Share this link with other users so they can request an access to your library.</translate></p>
+            <copy-input :value="library.fid" />
+          </div>
+        </div>
+      </div>
+      <h2><translate>Tracks</translate></h2>
+      <library-files-table :filters="{library: library.uuid}"></library-files-table>
+    </detail-area>
+  </div>
+</template>
+
+<script>
+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 {
+      hiddenForm: true
+    }
+  },
+  methods: {
+    libraryUpdated () {
+      this.hiddenForm = true
+      this.fetch()
+    },
+    libraryDeleted () {
+      this.$router.push({
+        name: 'content.libraries.index'
+      })
+    }
+  }
+}
+</script>
diff --git a/front/src/views/content/libraries/DetailArea.vue b/front/src/views/content/libraries/DetailArea.vue
new file mode 100644
index 0000000000000000000000000000000000000000..677984fbbd5ce07e63fa58eb6537f9968727e143
--- /dev/null
+++ b/front/src/views/content/libraries/DetailArea.vue
@@ -0,0 +1,37 @@
+<template>
+  <div>
+    <div class="ui stackable grid">
+      <div class="eleven wide stretched column">
+        <slot name="header"></slot>
+      </div>
+      <div class="five wide column">
+        <h3 class="ui header"><translate>Current library</translate></h3>
+        <library-card :library="library" />
+      </div>
+    </div>
+    <div class="ui divider"></div>
+    <slot></slot>
+  </div>
+</template>
+
+<script>
+import LibraryCard from './Card'
+
+export default {
+  props: ['library'],
+  components: {
+    LibraryCard
+  },
+  computed: {
+    links () {
+      let upload = this.$gettext('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
new file mode 100644
index 0000000000000000000000000000000000000000..92ff8452d88bbe026c413b92de9012c9a3401e8c
--- /dev/null
+++ b/front/src/views/content/libraries/DetailMixin.vue
@@ -0,0 +1,26 @@
+<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/Files.vue b/front/src/views/content/libraries/Files.vue
new file mode 100644
index 0000000000000000000000000000000000000000..752dcd77699056c6eac175d2faf571f37cb1a3e1
--- /dev/null
+++ b/front/src/views/content/libraries/Files.vue
@@ -0,0 +1,16 @@
+<template>
+  <div class="ui vertical aligned stripe segment">
+    <library-files-table :default-query="query"></library-files-table>
+  </div>
+</template>
+
+<script>
+import LibraryFilesTable from './FilesTable'
+
+export default {
+  props: ['query'],
+  components: {
+    LibraryFilesTable
+  }
+}
+</script>
diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9ff33ecd185ecc30a11196f3b30db1a20850081b
--- /dev/null
+++ b/front/src/views/content/libraries/FilesTable.vue
@@ -0,0 +1,303 @@
+<template>
+  <div>
+    <div class="ui inline form">
+      <div class="fields">
+        <div class="ui six wide field">
+          <label><translate>Search</translate></label>
+          <form @submit.prevent="search.query = $refs.search.value">
+            <input ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
+          </form>
+        </div>
+        <div class="field">
+          <label><translate>Import status</translate></label>
+          <select class="ui dropdown" @change="addSearchToken('status', $event.target.value)" :value="getTokenValue('status', '')">
+            <option value=""><translate>All</translate></option>
+            <option value="pending"><translate>Pending</translate></option>
+            <option value="skipped"><translate>Skipped</translate></option>
+            <option value="errored"><translate>Errored</translate></option>
+            <option value="finished"><translate>Finished</translate></option>
+          </select>
+        </div>
+        <div class="field">
+          <label><translate>Ordering</translate></label>
+          <select class="ui dropdown" v-model="ordering">
+            <option v-for="option in orderingOptions" :value="option[0]">
+              {{ option[1] }}
+            </option>
+          </select>
+        </div>
+        <div class="field">
+          <label><translate>Ordering direction</translate></label>
+          <select class="ui dropdown" v-model="orderingDirection">
+            <option value="+"><translate>Ascending</translate></option>
+            <option value="-"><translate>Descending</translate></option>
+          </select>
+        </div>
+      </div>
+    </div>
+    <div class="dimmable">
+      <div v-if="isLoading" class="ui active inverted dimmer">
+          <div class="ui loader"></div>
+      </div>
+      <action-table
+        v-if="result"
+        @action-launched="fetchData"
+        :id-field="'uuid'"
+        :objects-data="result"
+        :custom-objects="customObjects"
+        :actions="actions"
+        :action-url="'track-files/action/'"
+        :filters="actionFilters">
+        <template slot="header-cells">
+          <th><translate>Title</translate></th>
+          <th><translate>Artist</translate></th>
+          <th><translate>Album</translate></th>
+          <th><translate>Upload date</translate></th>
+          <th><translate>Import status</translate></th>
+          <th><translate>Duration</translate></th>
+          <th><translate>Size</translate></th>
+        </template>
+        <template slot="row-cells" slot-scope="scope">
+          <template v-if="scope.obj.track">
+            <td>
+              <span :title="scope.obj.track.title">{{ scope.obj.track.title|truncate(25) }}</span>
+            </td>
+            <td>
+              <span class="discrete link" @click="addSearchToken('artist', scope.obj.track.artist.name)" :title="scope.obj.track.artist.name">{{ scope.obj.track.artist.name|truncate(20) }}</span>
+            </td>
+            <td>
+              <span class="discrete link" @click="addSearchToken('album', scope.obj.track.album.title)" :title="scope.obj.track.album.title">{{ scope.obj.track.album.title|truncate(20) }}</span>
+            </td>
+          </template>
+          <template v-else>
+            <td>{{ scope.obj.source }}</td>
+            <td></td>
+            <td></td>
+          </template>
+          <td>
+            <human-date :date="scope.obj.creation_date"></human-date>
+          </td>
+          <td :title="labels.importStatuses[scope.obj.import_status].help">
+            <span class="discrete link" @click="addSearchToken('status', scope.obj.import_status)">
+              {{ labels.importStatuses[scope.obj.import_status].label }}
+              <i class="question circle outline icon"></i>
+            </span>
+          </td>
+          <td v-if="scope.obj.duration">
+            {{ time.parse(scope.obj.duration) }}
+          </td>
+          <td v-else>
+            <translate>N/A</translate>
+          </td>
+          <td v-if="scope.obj.size">
+            {{ scope.obj.size | humanSize }}
+          </td>
+          <td v-else>
+            <translate>N/A</translate>
+          </td>
+        </template>
+      </action-table>
+    </div>
+    <div>
+      <pagination
+        v-if="result && result.count > paginateBy"
+        @page-changed="selectPage"
+        :compact="true"
+        :current="page"
+        :paginate-by="paginateBy"
+        :total="result.count"
+        ></pagination>
+
+      <span v-if="result && result.results.length > 0">
+        <translate
+          :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}">
+          Showing results %{ start }-%{ end } on %{ total }
+        </translate>
+      </span>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import _ from 'lodash'
+import time from '@/utils/time'
+import {normalizeQuery, parseTokens, compileTokens} from '@/search'
+
+import Pagination from '@/components/Pagination'
+import ActionTable from '@/components/common/ActionTable'
+import OrderingMixin from '@/components/mixins/Ordering'
+
+export default {
+  mixins: [OrderingMixin],
+  props: {
+    filters: {type: Object, required: false},
+    defaultQuery: {type: String, default: ''},
+    customObjects: {type: Array, required: false, default: () => { return [] }}
+  },
+  components: {
+    Pagination,
+    ActionTable
+  },
+  data () {
+    return {
+      time,
+      isLoading: false,
+      result: null,
+      page: 1,
+      paginateBy: 25,
+      search: {
+        query: this.defaultQuery,
+        tokens: parseTokens(normalizeQuery(this.defaultQuery))
+      },
+      orderingDirection: '-',
+      ordering: 'creation_date',
+      orderingOptions: [
+        ['creation_date', 'Creation date'],
+        ['title', 'Title'],
+        ['size', 'Size'],
+        ['duration', 'Duration'],
+        ['bitrate', 'Bitrate'],
+        ['album_title', 'Album title'],
+        ['artist_name', 'Artist name']
+      ]
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    getTokenValue (key, fallback) {
+      let matching = this.search.tokens.filter(t => {
+        return t.field === key
+      })
+      if (matching.length > 0) {
+        return matching[0].value
+      }
+      return fallback
+    },
+    addSearchToken (key, value) {
+      if (!value) {
+        // we remove existing matching tokens, if any
+        this.search.tokens = this.search.tokens.filter(t => {
+          return t.field != key
+        })
+      } else {
+        let existing = this.search.tokens.filter(t => {
+          return t.field === key
+        })
+        if (existing.length > 0) {
+          // we replace the value in existing tokens, if any
+          existing.forEach(t => {
+            t.value = value
+          })
+        } else {
+          // we add a new token
+          this.search.tokens.push({field: key, value})
+        }
+      }
+    },
+    fetchData () {
+      let params = _.merge({
+        'page': this.page,
+        'page_size': this.paginateBy,
+        'ordering': this.getOrderingAsString(),
+        'q': this.search.query
+      }, {})
+      let self = this
+      self.isLoading = true
+      self.checked = []
+      axios.get('/track-files/', {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    selectPage: function (page) {
+      this.page = page
+    }
+  },
+  computed: {
+    labels () {
+      return {
+        searchPlaceholder: this.$gettext('Search by title, artist, album...'),
+        importStatuses: {
+          skipped: {
+            label: this.$gettext('Skipped'),
+            help: this.$gettext('Track was already present in one of your libraries'),
+          },
+          pending: {
+            label: this.$gettext('Pending'),
+            help: this.$gettext('Track is uploaded but not processed by the server yet'),
+          },
+          errored: {
+            label: this.$gettext('Errored'),
+            help: this.$gettext('An error occured while processing this track, ensure the track is correctly tagged'),
+          },
+          finished: {
+            label: this.$gettext('Finished'),
+            help: this.$gettext('Import went on successfully'),
+          },
+        }
+      }
+    },
+    actionFilters () {
+      var currentFilters = {
+        q: this.search.query
+      }
+      if (this.filters) {
+        return _.merge(currentFilters, this.filters)
+      } else {
+        return currentFilters
+      }
+    },
+    actions () {
+      let deleteMsg = this.$gettext('Delete')
+      let relaunchMsg = this.$gettext('Relaunch import')
+      return [
+        {
+          name: 'delete',
+          label: deleteMsg,
+          isDangerous: true,
+          allowAll: true
+        },
+        {
+          name: 'relaunch_import',
+          label: relaunchMsg,
+          isDangerous: true,
+          allowAll: true,
+          filterCheckable: f => {
+            return f.import_status != 'finished'
+          }
+        }
+      ]
+    }
+  },
+  watch: {
+    'search.query' (newValue) {
+      this.search.tokens = parseTokens(normalizeQuery(newValue))
+    },
+    'search.tokens': {
+      handler (newValue) {
+        this.search.query = compileTokens(newValue)
+        this.fetchData()
+      },
+      deep: true
+    },
+    orderingDirection: function () {
+      this.page = 1
+      this.fetchData()
+    },
+    ordering: function () {
+      this.page = 1
+      this.fetchData()
+    },
+    search (newValue) {
+      this.page = 1
+      this.fetchData()
+    },
+  }
+}
+</script>
diff --git a/front/src/views/content/libraries/Form.vue b/front/src/views/content/libraries/Form.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8650496bbd0b5416c124fc29d3be8d652c75a9a7
--- /dev/null
+++ b/front/src/views/content/libraries/Form.vue
@@ -0,0 +1,145 @@
+<template>
+  <form class="ui form" @submit.prevent="submit">
+    <p v-if="!library"><translate>Libraries help you organize and share your music collections. You can upload your own music collection to Funkwhale and share it with your friends and family.</translate></p>
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header"><translate>Error</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div class="required field">
+      <label><translate>Name</translate></label>
+      <input v-model="currentName" :placeholder="labels.namePlaceholder" required maxlength="100">
+    </div>
+    <div class="field">
+      <label><translate>Description</translate></label>
+      <textarea v-model="currentDescription" :placeholder="labels.descriptionPlaceholder" maxlength="2000"></textarea>
+    </div>
+    <div class="field">
+      <label><translate>Visibility</translate></label>
+      <p><translate>You will be able to share your library with other people, regardless of it's visibility.</translate></p>
+      <select class="ui dropdown" v-model="currentVisibilityLevel">
+        <option :value="c" v-for="c in ['me', 'instance', 'everyone']">{{ labels.visibility[c] }}</option>
+      </select>
+    </div>
+    <button class="ui submit button" type="submit">
+      <translate v-if="library">Update library</translate>
+      <translate v-else>Create library</translate>
+    </button>
+    <dangerous-button v-if="library" class="right floated basic button" color='red' @confirm="remove()">
+      <translate>Delete</translate>
+      <p slot="modal-header">
+        <translate>Delete this library?</translate>
+      </p>
+      <p slot="modal-content">
+        <translate>
+          The library and all its tracks will be deleted. This action is irreversible.
+        </translate>
+      </p>
+      <p slot="modal-confirm">
+        <translate>Delete library</translate>
+      </p>
+    </dangerous-button>
+  </form>
+</template>
+
+<script>
+import axios from 'axios'
+
+export default {
+  props: ['library'],
+  data () {
+    let d = {
+      isLoading: false,
+      over: false,
+      errors: []
+    }
+    if (this.library) {
+      d.currentVisibilityLevel = this.library.privacy_level
+      d.currentName = this.library.name
+      d.currentDescription = this.library.description
+    } else {
+      d.currentVisibilityLevel = 'me'
+      d.currentName = ''
+      d.currentDescription = ''
+    }
+    return d
+  },
+  computed: {
+    labels () {
+      let namePlaceholder = this.$gettext('My awesome library')
+      let descriptionPlaceholder = this.$gettext('This library contains my personnal music, I hope you will like it!')
+      let me = this.$gettext('Nobody except me')
+      let instance = this.$gettext('Everyone on this instance')
+      let everyone = this.$gettext('Everyone, including other instances')
+      return {
+        namePlaceholder,
+        descriptionPlaceholder,
+        visibility: {
+          me,
+          instance,
+          everyone
+        }
+      }
+    }
+  },
+  methods: {
+    submit () {
+      let self = this
+      this.isLoading = true
+      let payload = {
+        name: this.currentName,
+        description: this.currentDescription,
+        privacy_level: this.currentVisibilityLevel
+      }
+      let promise
+      if (this.library) {
+        promise = axios.patch(`libraries/${this.library.uuid}/`, payload)
+      } else {
+        promise = axios.post('libraries/', payload)
+      }
+      promise.then((response) => {
+        self.isLoading = false
+        let msg
+        if (self.library) {
+          self.$emit('updated', response.data)
+          msg = this.$gettext('Library updated')
+        } else {
+          self.$emit('created', response.data)
+          msg = this.$gettext('Library created')
+        }
+        self.$store.commit('ui/addMessage', {
+          content: msg,
+          date: new Date()
+        })
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    reset () {
+      this.currentVisibilityLevel = 'me'
+      this.currentName = ''
+      this.currentDescription = ''
+    },
+    remove () {
+      let self = this
+      axios.delete(`libraries/${this.library.uuid}/`).then((response) => {
+        self.isLoading = false
+        let msg = this.$gettext('Library deleted')
+        self.$emit('deleted', {})
+        self.$store.commit('ui/addMessage', {
+          content: msg,
+          date: new Date()
+        })
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+</style>
diff --git a/front/src/views/content/libraries/Home.vue b/front/src/views/content/libraries/Home.vue
new file mode 100644
index 0000000000000000000000000000000000000000..98dc2dde3e6fd6fc820427491d86533d1155c372
--- /dev/null
+++ b/front/src/views/content/libraries/Home.vue
@@ -0,0 +1,74 @@
+<template>
+  <div class="ui vertical aligned stripe segment">
+    <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
+      <div class="ui text loader"><translate>Loading Libraries...</translate></div>
+    </div>
+    <div v-else class="ui text container">
+      <h1 class="ui header"><translate>My libraries</translate></h1>
+
+      <p v-if="libraries.length == 0">
+        <translate>It looks like you don't have any library yet, it's time to create one!</translate>
+      </p>
+      <a @click="hiddenForm = !hiddenForm">
+        <i class="plus icon" v-if="hiddenForm" />
+        <i class="minus icon" v-else />
+        <translate>Create a new library</translate>
+      </a>
+      <library-form :library="null" v-if="!hiddenForm" @created="libraryCreated" />
+      <div class="ui hidden divider"></div>
+      <quota />
+      <div class="ui hidden divider"></div>
+      <div v-if="libraries.length > 0" class="ui two column grid">
+        <div v-for="library in libraries" :key="library.uuid" class="column">
+          <library-card :library="library" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import LibraryForm from './Form'
+import LibraryCard from './Card'
+import Quota from './Quota'
+
+export default {
+  data () {
+    return {
+      isLoading: false,
+      hiddenForm: true,
+      libraries: []
+    }
+  },
+  created () {
+    this.fetch()
+  },
+  components: {
+    LibraryForm,
+    LibraryCard,
+    Quota
+  },
+  methods: {
+    fetch () {
+      this.isLoading = true
+      let self = this
+      axios.get('libraries/').then((response) => {
+        self.isLoading = false
+        self.libraries = response.data.results
+        if (self.libraries.length === 0) {
+          self.hiddenForm = false
+        }
+      })
+    },
+    libraryCreated (library) {
+      this.hiddenForm = true
+      this.libraries.unshift(library)
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/content/libraries/Quota.vue b/front/src/views/content/libraries/Quota.vue
new file mode 100644
index 0000000000000000000000000000000000000000..edb0cbbeae66dcfa873da17867c9bb31f6996c44
--- /dev/null
+++ b/front/src/views/content/libraries/Quota.vue
@@ -0,0 +1,169 @@
+<template>
+  <div class="ui segment">
+    <h3 class="ui header"><translate>Current usage</translate></h3>
+    <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
+      <div class="ui text loader"><translate>Loading usage data...</translate></div>
+    </div>
+    <div :class="['ui', {'success': progress < 60}, {'yellow': progress >= 60 && progress < 96}, {'error': progress >= 95}, 'progress']">
+      <div class="bar">
+        <div class="progress">{{ progress }}%</div>
+      </div>
+      <div class="label" v-if="quotaStatus">
+        <translate :translate-params="{max: humanSize(quotaStatus.max * 1000 * 1000), current: humanSize(quotaStatus.current * 1000 * 1000)}">%{ current } used on %{ max } allowed</translate>
+      </div>
+    </div>
+    <div class="ui hidden divider"></div>
+    <div v-if="quotaStatus" class="ui stackable three column grid">
+      <div v-if="quotaStatus.pending > 0" class="column">
+        <div class="ui tiny yellow statistic">
+          <div class="value">
+            {{ humanSize(quotaStatus.pending * 1000 * 1000) }}
+          </div>
+          <div class="label">
+            <translate>Pending files</translate>
+          </div>
+        </div>
+        <div>
+          <router-link
+            class="ui basic blue tiny button"
+            :to="{name: 'content.libraries.files', query: {q: compileTokens([{field: 'status', value: 'pending'}])}}">
+            <translate>View files</translate>
+          </router-link>
+
+          <dangerous-button
+            color="grey"
+            class="basic tiny"
+            :action="purgePendingFiles">
+            <translate>Purge</translate>
+            <p slot="modal-header"><translate>Purge pending files?</translate></p>
+            <p slot="modal-content"><translate>This will remove tracks that were uploaded but not processed yet. This will remove those files completely and you will regain the corresponding quota.</translate></p>
+            <p slot="modal-confirm"><translate>Purge</translate></p>
+          </dangerous-button>
+        </div>
+      </div>
+      <div v-if="quotaStatus.skipped > 0" class="column">
+        <div class="ui tiny grey statistic">
+          <div class="value">
+            {{ humanSize(quotaStatus.skipped * 1000 * 1000) }}
+          </div>
+          <div class="label">
+            <translate>Skipped files</translate>
+          </div>
+        </div>
+        <div>
+          <router-link
+            class="ui basic blue tiny button"
+            :to="{name: 'content.libraries.files', query: {q: compileTokens([{field: 'status', value: 'skipped'}])}}">
+            <translate>View files</translate>
+          </router-link>
+          <dangerous-button
+            color="grey"
+            class="basic tiny"
+            :action="purgeSkippedFiles">
+            <translate>Purge</translate>
+            <p slot="modal-header"><translate>Purge skipped files?</translate></p>
+            <p slot="modal-content"><translate>This will remove tracks that were uploaded but skipped during import processes for various reasons. This will remove those files completely and you will regain the corresponding quota.</translate></p>
+            <p slot="modal-confirm"><translate>Purge</translate></p>
+          </dangerous-button>
+        </div>
+      </div>
+      <div v-if="quotaStatus.errored > 0" class="column">
+        <div class="ui tiny red statistic">
+          <div class="value">
+            {{ humanSize(quotaStatus.errored * 1000 * 1000) }}
+          </div>
+          <div class="label">
+            <translate>Errored files</translate>
+          </div>
+        </div>
+        <div>
+          <router-link
+            class="ui basic blue tiny button"
+            :to="{name: 'content.libraries.files', query: {q: compileTokens([{field: 'status', value: 'errored'}])}}">
+            <translate>View files</translate>
+          </router-link>
+          <dangerous-button
+            color="grey"
+            class="basic tiny"
+            :action="purgeErroredFiles">
+            <translate>Purge</translate>
+            <p slot="modal-header"><translate>Purge errored files?</translate></p>
+            <p slot="modal-content"><translate>This will remove tracks that were uploaded but failed to be process by the server. This will remove those files completely and you will regain the corresponding quota.</translate></p>
+            <p slot="modal-confirm"><translate>Purge</translate></p>
+          </dangerous-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+import axios from 'axios'
+import $ from 'jquery'
+import {humanSize} from '@/filters'
+import {compileTokens} from '@/search'
+
+export default {
+  data () {
+    return {
+      quotaStatus: null,
+      isLoading: false,
+      humanSize,
+      compileTokens
+    }
+  },
+  created () {
+    this.fetch()
+  },
+  methods: {
+    fetch () {
+      let self = this
+      self.isLoading = true
+      axios.get('users/users/me/').then((response) => {
+        self.quotaStatus = response.data.quota_status
+        self.isLoading = false
+      })
+    },
+    purge (status) {
+      let self = this
+      let payload = {
+        action: 'delete',
+        objects: 'all',
+        filters: {
+          import_status: status
+        }
+      }
+      axios.post('track-files/action/', payload).then((response) => {
+        self.fetch()
+      })
+    },
+    purgeSkippedFiles () {
+      this.purge('skipped')
+    },
+    purgePendingFiles () {
+      this.purge('pending')
+    },
+    purgeErroredFiles () {
+      this.purge('errored')
+    },
+    updateProgressBar () {
+      $(this.$el).find('.ui.progress').progress({
+        percent: this.progress,
+        showActivity: false
+      })
+    }
+  },
+  computed: {
+    progress () {
+      if (!this.quotaStatus) {
+        return 0
+      }
+      return Math.min(parseInt(this.quotaStatus.current * 100 / this.quotaStatus.max), 100)
+    }
+  },
+  watch: {
+    progress () {
+      this.updateProgressBar()
+    }
+  }
+}
+</script>
diff --git a/front/src/views/content/libraries/Upload.vue b/front/src/views/content/libraries/Upload.vue
new file mode 100644
index 0000000000000000000000000000000000000000..68ee3b9338558d4347778eaa11740d5698436ac8
--- /dev/null
+++ b/front/src/views/content/libraries/Upload.vue
@@ -0,0 +1,43 @@
+<template>
+  <div class="ui vertical aligned stripe segment">
+    <div v-if="isLoadingLibrary" :class="['ui', {'active': isLoadingLibrary}, 'inverted', 'dimmer']">
+      <div class="ui text loader"><translate>Loading library data...</translate></div>
+    </div>
+    <detail-area v-else :library="library">
+      <div slot="header">
+        <h2 class="ui header"><translate>Upload new tracks</translate></h2>
+        <div class="ui message">
+          <p><translate>You are about to upload music to your library. Before proceeding, please ensure that:</translate></p>
+          <ul>
+            <li v-if="library.privacy_level != 'me'">
+              You are not uploading copyrighted content in a public library, otherwise you may be infringing the law
+            </li>
+            <li>
+              <translate>The music files you are uploading are tagged properly:</translate>
+              <a href="http://picard.musicbrainz.org/" target='_blank'><translate>we recommend using Picard for that purpose</translate></a>
+            </li>
+            <li>
+              <translate>The uploaded music files are in OGG, Flac or MP3 format</translate>
+            </li>
+          </ul>
+        </div>
+      </div>
+      <file-upload :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
+  }
+}
+</script>
diff --git a/front/src/views/content/remote/Card.vue b/front/src/views/content/remote/Card.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b9903d94b7197117dc69af5db4ae8c73d4d1fc7a
--- /dev/null
+++ b/front/src/views/content/remote/Card.vue
@@ -0,0 +1,89 @@
+<template>
+  <div class="ui fluid card">
+    <div class="content">
+      <div class="header">
+        {{ library.name }}
+        <span
+          v-if="library.privacy_level === 'me'"
+          class="right floated"
+          :data-tooltip="labels.tooltips.me">
+          <i class="small lock icon"></i>
+        </span>
+        <span
+          v-else-if="library.privacy_level === 'everyone'"
+          class="right floated"
+          :data-tooltip="labels.tooltips.everyone">
+          <i class="small globe icon"></i>
+        </span>
+      </div>
+      <div class="meta">
+        <span>
+          <i class="small outline clock icon" />
+          <human-date :date="library.creation_date" />
+        </span>
+      </div>
+      <div class="content">
+        <i class="music icon"></i>
+        <translate :translate-params="{count: library.files_count}" :translate-n="library.files_count" translate-plural="%{ count } tracks">1 tracks</translate>
+      </div>
+    </div>
+    <div class="extra content">
+      <actor-link :actor="library.actor" />
+    </div>
+    <div class="ui bottom attached buttons">
+      <button
+        v-if="!library.follow"
+        @click="follow()"
+        :class="['ui', 'green', {'loading': isLoadingFollow}, 'button']">
+        <translate>Follow</translate>
+      </button>
+      <button
+        v-else-if="!library.follow.approved"
+        class="ui disabled button"><i class="hourglass icon"></i>
+        <translate>Follow pending approval</translate>
+      </button>
+      <button
+        v-else-if="!library.follow.approved"
+        class="ui disabled button"><i class="check icon"></i>
+        <translate>Following</translate>
+      </button>
+    </div>
+  </div>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  props: ['library'],
+  data () {
+    return {
+      isLoadingFollow: false
+    }
+  },
+  computed: {
+    labels () {
+      let me = this.$gettext('This library is private and you will need approval from its owner to access its content')
+      let everyone = this.$gettext('This library is public and you can access its content without any authorization')
+
+      return {
+        tooltips: {
+          me,
+          everyone
+        }
+      }
+    }
+  },
+  methods: {
+    follow () {
+      let self = this
+      this.isLoadingFollow = true
+      axios.post('federation/follows/library/', {target: this.library.uuid}).then((response) => {
+        self.library.follow = response.data
+        self.isLoadingFollow = false
+      }, error => {
+        self.isLoadingFollow = false
+      })
+    }
+  }
+}
+</script>
diff --git a/front/src/views/content/remote/Home.vue b/front/src/views/content/remote/Home.vue
new file mode 100644
index 0000000000000000000000000000000000000000..753e5fa2b04eabbb024c5f692d12ab2ec135aa93
--- /dev/null
+++ b/front/src/views/content/remote/Home.vue
@@ -0,0 +1,55 @@
+<template>
+  <div class="ui vertical aligned stripe segment">
+    <div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
+      <div class="ui text loader"><translate>Loading remote libraries...</translate></div>
+    </div>
+    <div v-else class="ui text container">
+      <h1 class="ui header"><translate>Remote libraries</translate></h1>
+      <p><translate>Remote libraries are owned by other users on the network. You can access them as long as they are public or you are granted access.</translate></p>
+      <scan-form @scanned="scanResult = $event"></scan-form>
+      <div class="ui hidden divider"></div>
+      <div v-if="scanResult && scanResult.results.length > 0" class="ui two cards">
+        <library-card :library="library" v-for="library in scanResult.results" :key="library.fid" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import ScanForm from './ScanForm'
+import LibraryCard from './Card'
+
+export default {
+  data () {
+    return {
+      isLoading: false,
+      scanResult: null
+    }
+  },
+  created () {
+    // this.fetch()
+  },
+  components: {
+    ScanForm,
+    LibraryCard
+  },
+  methods: {
+    fetch () {
+      this.isLoading = true
+      let self = this
+      axios.get('libraries/').then((response) => {
+        self.isLoading = false
+        self.libraries = response.data.results
+        if (self.libraries.length === 0) {
+          self.hiddenForm = false
+        }
+      })
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/content/remote/ScanForm.vue b/front/src/views/content/remote/ScanForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9ff614efb45b6f4fe44b921a645b8c80a80a6889
--- /dev/null
+++ b/front/src/views/content/remote/ScanForm.vue
@@ -0,0 +1,55 @@
+<template>
+  <form class="ui form" @submit.prevent="scan">
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header"><translate>Error while fetching remote library</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div class="ui field">
+      <label><translate>Search a remote library</translate></label>
+      <div :class="['ui', 'action', {loading: isLoading}, 'input']">
+        <input v-model="query" :placeholder="labels.placeholder" type="url">
+        <button type="submit" class="ui icon button">
+          <i class="search icon"></i>
+        </button>
+      </div>
+    </div>
+  </form>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  data () {
+    return {
+      query: '',
+      isLoading: false,
+      errors: []
+    }
+  },
+  methods: {
+    scan () {
+      if (!this.query) {
+        return
+      }
+      let self = this
+      axios.post('federation/libraries/scan/', {fid: this.query}).then((response) => {
+        self.$emit('scanned', response.data)
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  },
+  computed: {
+    labels () {
+      let placeholder = this.$gettext('Enter a library url')
+      return {
+        placeholder
+      }
+    }
+  }
+}
+</script>
diff --git a/front/tests/unit/specs/search.spec.js b/front/tests/unit/specs/search.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..5cc551b20f4aca9cd8cd1dc99e227f1ab925d338
--- /dev/null
+++ b/front/tests/unit/specs/search.spec.js
@@ -0,0 +1,65 @@
+import {expect} from 'chai'
+
+import {normalizeQuery, parseTokens, compileTokens} from '@/search'
+
+describe('search', () => {
+  it('normalizeQuery returns correct tokens', () => {
+    const input = 'this is a "search query" yeah'
+    let output = normalizeQuery(input)
+    expect(output).to.deep.equal(['this', 'is', 'a', 'search query', 'yeah'])
+  })
+  it('parseTokens can extract fields and values from tokens', () => {
+    const input = ['unhandled', 'key:value', 'status:pending', 'title:"some title"', 'anotherunhandled']
+    let output = parseTokens(input)
+    let expected = [
+      {
+        'field': null,
+        'value': 'unhandled'
+      },
+      {
+        'field': 'key',
+        'value': 'value'
+      },
+      {
+        'field': 'status',
+        'value': 'pending',
+      },
+      {
+        'field': 'title',
+        'value': 'some title'
+      },
+      {
+        'field': null,
+        'value': 'anotherunhandled'
+      }
+    ]
+    expect(output).to.deep.equal(expected)
+  })
+  it('compileTokens returns proper query string', () => {
+    let input = [
+      {
+        'field': null,
+        'value': 'unhandled'
+      },
+      {
+        'field': 'key',
+        'value': 'value'
+      },
+      {
+        'field': 'status',
+        'value': 'pending',
+      },
+      {
+        'field': 'title',
+        'value': 'some title'
+      },
+      {
+        'field': null,
+        'value': 'anotherunhandled'
+      }
+    ]
+    const expected = 'unhandled key:value status:pending title:"some title" anotherunhandled'
+    let output = compileTokens(input)
+    expect(output).to.deep.equal(expected)
+  })
+})
diff --git a/per-user-libraries b/per-user-libraries
new file mode 100644
index 0000000000000000000000000000000000000000..d2905d88acee544a3689d1f640316ef69910279e
--- /dev/null
+++ b/per-user-libraries
@@ -0,0 +1,51 @@
+Todo:
+
+- upload utilisateur
+- gestion des doublons ? Si piste uploadée deux fois, on fait quoi:
+    - On rajoute un lien entre la piste existante et la bibliothèque de l'utilisateur
+    - L'utilisateur peut forcer l'upload de SA piste
+- Comment on gère l'affichage : une piste peut ne pas être jouable
+- more tests about:
+    - replacing
+    - deletion
+    - permissions
+
+- un utilisateur envoie une piste:
+    - pas de problème: au pire les métadonnées ne sont pas bonnes mais ce n'est pas très grave
+    - on incite à tagguer avec picard / musicbrainz
+    - en cas de conflit (piste sans tag, mais le nom de l'artiste existe avec un ID musicbrainz, par exemple):
+      on fournit à l'utilisateur le choix (binder à l'artiste musicbrainz), ou créer un artiste séparé
+      on peut aussi tenter des trucs plus intelligents à base de matching sur les noms de pistes, mais dans un second temps
+
+- Un créateur envoie une piste:
+    - il crée un profil avec son nom d'artiste, des liens vers ses différents profils (youtube, etc).
+    - on peut lui fournir un snippet à inclure sur ses profils "Funkwhale: http://creator.url" pour servir à valider
+    - les instances fédérées pourront donc faire la vérification elles-mêmes
+    - à l'upload, il a un formulaire spécial ou il déclare bien être le créateur des pistes et avoir les droits
+    - on ne tente pas d'être smart : il faut que les données soient fiables et éditables par le créateur avant publication !
+
+
+Jour 2:
+
+- on bind les fichiers aux bibliothèques
+- on ne dédoublonne pas, trop compliqué
+- en fédé, quand on scanne, on crée les track files, du coup plus besoin d'import manuel
+- dans le script de migration, gérer le cas dess trucs importés via la fédé
+
+
+Todo:
+
+- tester le remplacement
+- skipper les tracks qui sont déjà dans une autre bibliothèque
+- gestion d'erreur plus poussée
+- gérer les radios
+- tester permission sur la fédé
+- tester qu'on ne sert que les bibliothèques locales
+- virer au maximum la logique custom pour les acteurs systèmes
+- utiliser le vrai champs durée d'activitystream pour l'audio
+- shared inbox url
+- vue pour servir les bibliothèques:
+
+- logique de scan:
+    - pouvoir lancer, mettre en pause, interrompre un scan
+    - avoir des infos sur le déroulement du scan