diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index 74c5e248da20c77ed311eeb1572796b3cf299ba9..e67e432f65ba2eabab52e3ffb1defde2a5124aed 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -17,6 +17,7 @@ router = common_routers.OptionalSlashRouter()
 router.register(r"settings", GlobalPreferencesViewSet, basename="settings")
 router.register(r"activity", activity_views.ActivityViewSet, "activity")
 router.register(r"tags", tags_views.TagViewSet, "tags")
+router.register(r"plugins", common_views.PluginViewSet, "plugins")
 router.register(r"tracks", views.TrackViewSet, "tracks")
 router.register(r"uploads", views.UploadViewSet, "uploads")
 router.register(r"libraries", views.LibraryViewSet, "libraries")
diff --git a/api/config/plugins.py b/api/config/plugins.py
new file mode 100644
index 0000000000000000000000000000000000000000..f390c4366dc18573dba618aea45379fa397affce
--- /dev/null
+++ b/api/config/plugins.py
@@ -0,0 +1,291 @@
+import copy
+import logging
+import os
+import subprocess
+import sys
+
+import persisting_theory
+from django.db.models import Q
+
+from rest_framework import serializers
+
+logger = logging.getLogger("plugins")
+
+
+class Startup(persisting_theory.Registry):
+    look_into = "persisting_theory"
+
+
+class Ready(persisting_theory.Registry):
+    look_into = "persisting_theory"
+
+
+startup = Startup()
+ready = Ready()
+
+_plugins = {}
+_filters = {}
+_hooks = {}
+
+
+def get_plugin_config(
+    name,
+    user=False,
+    source=False,
+    registry=_plugins,
+    conf={},
+    description=None,
+    version=None,
+    label=None,
+):
+    conf = {
+        "name": name,
+        "label": label or name,
+        "logger": logger,
+        "conf": conf,
+        "user": True if source else user,
+        "source": source,
+        "description": description,
+        "version": version,
+    }
+    registry[name] = conf
+    return conf
+
+
+def get_session():
+    from funkwhale_api.common import session
+
+    return session.get_session()
+
+
+def register_filter(name, plugin_config, registry=_filters):
+    def decorator(func):
+        handlers = registry.setdefault(name, [])
+
+        def inner(*args, **kwargs):
+            plugin_config["logger"].debug("Calling filter for %s", name)
+            rval = func(*args, **kwargs)
+            return rval
+
+        handlers.append((plugin_config["name"], inner))
+        return inner
+
+    return decorator
+
+
+def register_hook(name, plugin_config, registry=_hooks):
+    def decorator(func):
+        handlers = registry.setdefault(name, [])
+
+        def inner(*args, **kwargs):
+            plugin_config["logger"].debug("Calling hook for %s", name)
+            func(*args, **kwargs)
+
+        handlers.append((plugin_config["name"], inner))
+        return inner
+
+    return decorator
+
+
+class Skip(Exception):
+    pass
+
+
+def trigger_filter(name, value, enabled=False, **kwargs):
+    """
+    Call filters registered for "name" with the given
+    args and kwargs.
+
+    Return the value (that could be modified by handlers)
+    """
+    logger.debug("Calling handlers for filter %s", name)
+    registry = kwargs.pop("registry", _filters)
+    confs = kwargs.pop("confs", {})
+    for plugin_name, handler in registry.get(name, []):
+        if not enabled and confs.get(plugin_name, {}).get("enabled") is False:
+            continue
+        try:
+            value = handler(value, conf=confs.get(plugin_name, {}), **kwargs)
+        except Skip:
+            pass
+        except Exception as e:
+            logger.warn("Plugin %s errored during filter %s: %s", plugin_name, name, e)
+    return value
+
+
+def trigger_hook(name, enabled=False, **kwargs):
+    """
+    Call hooks registered for "name" with the given
+    args and kwargs.
+
+    Returns nothing
+    """
+    logger.debug("Calling handlers for hook %s", name)
+    registry = kwargs.pop("registry", _hooks)
+    confs = kwargs.pop("confs", {})
+    for plugin_name, handler in registry.get(name, []):
+        if not enabled and confs.get(plugin_name, {}).get("enabled") is False:
+            continue
+        try:
+            handler(conf=confs.get(plugin_name, {}).get("conf"), **kwargs)
+        except Skip:
+            pass
+        except Exception as e:
+            logger.warn("Plugin %s errored during hook %s: %s", plugin_name, name, e)
+
+
+def set_conf(name, conf, user=None, registry=_plugins):
+    from funkwhale_api.common import models
+
+    if not registry[name]["conf"] and not registry[name]["source"]:
+        return
+    conf_serializer = get_serializer_from_conf_template(
+        registry[name]["conf"], user=user, source=registry[name]["source"],
+    )(data=conf)
+    conf_serializer.is_valid(raise_exception=True)
+    if "library" in conf_serializer.validated_data:
+        conf_serializer.validated_data["library"] = str(
+            conf_serializer.validated_data["library"]
+        )
+    conf, _ = models.PluginConfiguration.objects.update_or_create(
+        user=user, code=name, defaults={"conf": conf_serializer.validated_data}
+    )
+
+
+def get_confs(user=None):
+    from funkwhale_api.common import models
+
+    qs = models.PluginConfiguration.objects.filter(code__in=list(_plugins.keys()))
+    if user:
+        qs = qs.filter(Q(user=None) | Q(user=user))
+    else:
+        qs = qs.filter(user=None)
+    confs = {
+        v["code"]: {"conf": v["conf"], "enabled": v["enabled"]}
+        for v in qs.values("code", "conf", "enabled")
+    }
+    for p, v in _plugins.items():
+        if p not in confs:
+            confs[p] = {"conf": None, "enabled": False}
+    return confs
+
+
+def get_conf(plugin, user=None):
+    return get_confs(user=user)[plugin]
+
+
+def enable_conf(code, value, user):
+    from funkwhale_api.common import models
+
+    models.PluginConfiguration.objects.update_or_create(
+        code=code, user=user, defaults={"enabled": value}
+    )
+
+
+class LibraryField(serializers.UUIDField):
+    def __init__(self, *args, **kwargs):
+        self.actor = kwargs.pop("actor")
+        super().__init__(*args, **kwargs)
+
+    def to_internal_value(self, v):
+        v = super().to_internal_value(v)
+        if not self.actor.libraries.filter(uuid=v).first():
+            raise serializers.ValidationError("Invalid library id")
+        return v
+
+
+def get_serializer_from_conf_template(conf, source=False, user=None):
+    conf = copy.deepcopy(conf)
+    validators = {f["name"]: f.pop("validator") for f in conf if "validator" in f}
+    mapping = {
+        "url": serializers.URLField,
+        "boolean": serializers.BooleanField,
+        "text": serializers.CharField,
+        "long_text": serializers.CharField,
+        "password": serializers.CharField,
+        "number": serializers.IntegerField,
+    }
+
+    for attr in ["label", "help"]:
+        for c in conf:
+            c.pop(attr, None)
+
+    class Serializer(serializers.Serializer):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            for field_conf in conf:
+                field_kwargs = copy.copy(field_conf)
+                name = field_kwargs.pop("name")
+                self.fields[name] = mapping[field_kwargs.pop("type")](**field_kwargs)
+            if source:
+                self.fields["library"] = LibraryField(actor=user.actor)
+
+    for vname, v in validators.items():
+        setattr(Serializer, "validate_{}".format(vname), v)
+    return Serializer
+
+
+def serialize_plugin(plugin_conf, confs):
+    return {
+        "name": plugin_conf["name"],
+        "label": plugin_conf["label"],
+        "description": plugin_conf.get("description") or None,
+        "user": plugin_conf.get("user", False),
+        "source": plugin_conf.get("source", False),
+        "conf": plugin_conf.get("conf", None),
+        "values": confs.get(plugin_conf["name"], {"conf"}).get("conf"),
+        "enabled": plugin_conf["name"] in confs
+        and confs[plugin_conf["name"]]["enabled"],
+    }
+
+
+def install_dependencies(deps):
+    if not deps:
+        return
+    logger.info("Installing plugins dependencies %s", deps)
+    pip_path = os.path.join(os.path.dirname(sys.executable), "pip")
+    subprocess.check_call([pip_path, "install"] + deps)
+
+
+def background_task(name):
+    from funkwhale_api.taskapp import celery
+
+    def decorator(func):
+        return celery.app.task(func, name=name)
+
+    return decorator
+
+
+# HOOKS
+LISTENING_CREATED = "listening_created"
+"""
+Called when a track is being listened
+"""
+SCAN = "scan"
+"""
+
+"""
+# FILTERS
+PLUGINS_DEPENDENCIES = "plugins_dependencies"
+"""
+Called with an empty list, use this filter to append pip dependencies
+to the list for installation.
+"""
+PLUGINS_APPS = "plugins_apps"
+"""
+Called with an empty list, use this filter to append apps to INSTALLED_APPS
+"""
+MIDDLEWARES_BEFORE = "middlewares_before"
+"""
+Called with an empty list, use this filter to prepend middlewares
+to MIDDLEWARE
+"""
+MIDDLEWARES_AFTER = "middlewares_after"
+"""
+Called with an empty list, use this filter to append middlewares
+to MIDDLEWARE
+"""
+URLS = "urls"
+"""
+Called with an empty list, use this filter to register new urls and views
+"""
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index ad24d43db29dddc6c18f0d2ddc5f263a7a18b222..8dc16537e8127e9588cd7c9f7c44cfe634e6468e 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -46,6 +46,12 @@ logging.config.dictConfig(
                 # required to avoid double logging with root logger
                 "propagate": False,
             },
+            "plugins": {
+                "level": LOGLEVEL,
+                "handlers": ["console"],
+                # required to avoid double logging with root logger
+                "propagate": False,
+            },
             "": {"level": "WARNING", "handlers": ["console"]},
         },
     }
@@ -87,6 +93,20 @@ Path to a directory containing Funkwhale plugins. These will be imported at runt
 """
 sys.path.append(FUNKWHALE_PLUGINS_PATH)
 
+PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p]
+"""
+List of Funkwhale plugins to load.
+"""
+if PLUGINS:
+    logger.info("Running with the following plugins enabled: %s", ", ".join(PLUGINS))
+else:
+    logger.info("Running with no plugins")
+
+from .. import plugins  # noqa
+
+plugins.startup.autodiscover([p + ".funkwhale_startup" for p in PLUGINS])
+DEPENDENCIES = plugins.trigger_filter(plugins.PLUGINS_DEPENDENCIES, [], enabled=True)
+plugins.install_dependencies(DEPENDENCIES)
 FUNKWHALE_HOSTNAME = None
 FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None)
 FUNKWHALE_HOSTNAME_PREFIX = env("FUNKWHALE_HOSTNAME_PREFIX", default=None)
@@ -247,16 +267,6 @@ LOCAL_APPS = (
 
 # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
 
-
-PLUGINS = [p for p in env.list("FUNKWHALE_PLUGINS", default=[]) if p]
-"""
-List of Funkwhale plugins to load.
-"""
-if PLUGINS:
-    logger.info("Running with the following plugins enabled: %s", ", ".join(PLUGINS))
-else:
-    logger.info("Running with no plugins")
-
 ADDITIONAL_APPS = env.list("ADDITIONAL_APPS", default=[])
 """
 List of Django apps to load in addition to Funkwhale plugins and apps.
@@ -265,27 +275,32 @@ INSTALLED_APPS = (
     DJANGO_APPS
     + THIRD_PARTY_APPS
     + LOCAL_APPS
-    + tuple(["{}.apps.Plugin".format(p) for p in PLUGINS])
     + tuple(ADDITIONAL_APPS)
+    + tuple(plugins.trigger_filter(plugins.PLUGINS_APPS, [], enabled=True))
 )
 
 # MIDDLEWARE CONFIGURATION
 # ------------------------------------------------------------------------------
 ADDITIONAL_MIDDLEWARES_BEFORE = env.list("ADDITIONAL_MIDDLEWARES_BEFORE", default=[])
-MIDDLEWARE = tuple(ADDITIONAL_MIDDLEWARES_BEFORE) + (
-    "django.middleware.security.SecurityMiddleware",
-    "django.middleware.clickjacking.XFrameOptionsMiddleware",
-    "corsheaders.middleware.CorsMiddleware",
-    # needs to be before SPA middleware
-    "django.contrib.sessions.middleware.SessionMiddleware",
-    "django.middleware.common.CommonMiddleware",
-    "django.middleware.csrf.CsrfViewMiddleware",
-    # /end
-    "funkwhale_api.common.middleware.SPAFallbackMiddleware",
-    "django.contrib.auth.middleware.AuthenticationMiddleware",
-    "django.contrib.messages.middleware.MessageMiddleware",
-    "funkwhale_api.users.middleware.RecordActivityMiddleware",
-    "funkwhale_api.common.middleware.ThrottleStatusMiddleware",
+MIDDLEWARE = (
+    tuple(plugins.trigger_filter(plugins.MIDDLEWARES_BEFORE, [], enabled=True))
+    + tuple(ADDITIONAL_MIDDLEWARES_BEFORE)
+    + (
+        "django.middleware.security.SecurityMiddleware",
+        "django.middleware.clickjacking.XFrameOptionsMiddleware",
+        "corsheaders.middleware.CorsMiddleware",
+        # needs to be before SPA middleware
+        "django.contrib.sessions.middleware.SessionMiddleware",
+        "django.middleware.common.CommonMiddleware",
+        "django.middleware.csrf.CsrfViewMiddleware",
+        # /end
+        "funkwhale_api.common.middleware.SPAFallbackMiddleware",
+        "django.contrib.auth.middleware.AuthenticationMiddleware",
+        "django.contrib.messages.middleware.MessageMiddleware",
+        "funkwhale_api.users.middleware.RecordActivityMiddleware",
+        "funkwhale_api.common.middleware.ThrottleStatusMiddleware",
+    )
+    + tuple(plugins.trigger_filter(plugins.MIDDLEWARES_AFTER, [], enabled=True))
 )
 
 # DEBUG
diff --git a/api/config/urls.py b/api/config/urls.py
index 2cd4f466299231ebc5dc1e6d40b7afb49e9919c2..3af9bd87d95c0771b1c330b1ddc11b8bf0b78333 100644
--- a/api/config/urls.py
+++ b/api/config/urls.py
@@ -8,7 +8,9 @@ from django.conf.urls.static import static
 from funkwhale_api.common import admin
 from django.views import defaults as default_views
 
+from config import plugins
 
+plugins_patterns = plugins.trigger_filter(plugins.URLS, [], enabled=True)
 urlpatterns = [
     # Django Admin, use {% url 'admin:index' %}
     url(settings.ADMIN_URL, admin.site.urls),
@@ -21,8 +23,7 @@ urlpatterns = [
     ),
     url(r"^api/v1/auth/", include("funkwhale_api.users.rest_auth_urls")),
     url(r"^accounts/", include("allauth.urls")),
-    # Your stuff: custom urls includes go here
-]
+] + plugins_patterns
 
 if settings.DEBUG:
     # This allows the error pages to be debugged during development, just visit
diff --git a/api/funkwhale_api/cli/main.py b/api/funkwhale_api/cli/main.py
index 1453ca5d2569de7f37b633459121754d563e526d..db7e87a22c19c80864a0df1039e8dfc18ebca993 100644
--- a/api/funkwhale_api/cli/main.py
+++ b/api/funkwhale_api/cli/main.py
@@ -4,6 +4,7 @@ import sys
 from . import base
 from . import library  # noqa
 from . import media  # noqa
+from . import plugins  # noqa
 from . import users  # noqa
 
 from rest_framework.exceptions import ValidationError
diff --git a/api/funkwhale_api/cli/plugins.py b/api/funkwhale_api/cli/plugins.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ab24dccfb0c07c2a20839749ab9bafe05030845
--- /dev/null
+++ b/api/funkwhale_api/cli/plugins.py
@@ -0,0 +1,35 @@
+import os
+import subprocess
+import sys
+
+import click
+from django.conf import settings
+
+
+from . import base
+
+
+@base.cli.group()
+def plugins():
+    """Manage plugins"""
+    pass
+
+
+@plugins.command("install")
+@click.argument("plugin", nargs=-1)
+def install(plugin):
+    """
+    Install a plugin from a given URL (zip, pip or git are supported)
+    """
+    if not plugin:
+        return click.echo("No plugin provided")
+
+    click.echo("Installing plugins…")
+    pip_install(list(plugin), settings.FUNKWHALE_PLUGINS_PATH)
+
+
+def pip_install(deps, target):
+    if not deps:
+        return
+    pip_path = os.path.join(os.path.dirname(sys.executable), "pip")
+    subprocess.check_call([pip_path, "install", "-t", target] + deps)
diff --git a/api/funkwhale_api/common/apps.py b/api/funkwhale_api/common/apps.py
index 7d94695a1f349c07d5f8e681af6c98ce42bc63e9..afd834a5ad72ab76827fda27cb74c93f187829da 100644
--- a/api/funkwhale_api/common/apps.py
+++ b/api/funkwhale_api/common/apps.py
@@ -1,4 +1,7 @@
 from django.apps import AppConfig, apps
+from django.conf import settings
+
+from config import plugins
 
 from . import mutations
 from . import utils
@@ -13,3 +16,4 @@ class CommonConfig(AppConfig):
         app_names = [app.name for app in apps.app_configs.values()]
         mutations.registry.autodiscover(app_names)
         utils.monkey_patch_request_build_absolute_uri()
+        plugins.startup.autodiscover([p + ".funkwhale_ready" for p in settings.PLUGINS])
diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py
index 9af602de7df70ce22f663161a59cd3e1d56aed22..f897f5532edbb02f9c433be407e2e23462cbf3cd 100644
--- a/api/funkwhale_api/common/factories.py
+++ b/api/funkwhale_api/common/factories.py
@@ -35,3 +35,12 @@ class CommonFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
 
     class Meta:
         model = "common.Content"
+
+
+@registry.register
+class PluginConfiguration(NoUpdateOnCreate, factory.django.DjangoModelFactory):
+    code = "test"
+    conf = {"foo": "bar"}
+
+    class Meta:
+        model = "common.PluginConfiguration"
diff --git a/api/funkwhale_api/common/middleware.py b/api/funkwhale_api/common/middleware.py
index 64bb6f80bfa49ac4d2a5416142c863f82c03956c..b085a18fe84c78b423d33d5603a4887a7ba9f341 100644
--- a/api/funkwhale_api/common/middleware.py
+++ b/api/funkwhale_api/common/middleware.py
@@ -11,6 +11,7 @@ from django import http
 from django.conf import settings
 from django.core.cache import caches
 from django.middleware import csrf
+from django.contrib import auth
 from django import urls
 from rest_framework import views
 
@@ -282,6 +283,25 @@ def monkey_patch_rest_initialize_request():
 monkey_patch_rest_initialize_request()
 
 
+def monkey_patch_auth_get_user():
+    """
+    We need an actor on our users for many endpoints, so we monkey patch
+    auth.get_user to create it if it's missing
+    """
+    original = auth.get_user
+
+    def replacement(request):
+        r = original(request)
+        if not r.is_anonymous and not r.actor:
+            r.create_actor()
+        return r
+
+    setattr(auth, "get_user", replacement)
+
+
+monkey_patch_auth_get_user()
+
+
 class ThrottleStatusMiddleware:
     """
     Include useful information regarding throttling in API responses to
diff --git a/api/funkwhale_api/common/migrations/0008_auto_20200701_1317.py b/api/funkwhale_api/common/migrations/0008_auto_20200701_1317.py
new file mode 100644
index 0000000000000000000000000000000000000000..3abfff42dc3a09108312e94a2ec60f5263ce1c58
--- /dev/null
+++ b/api/funkwhale_api/common/migrations/0008_auto_20200701_1317.py
@@ -0,0 +1,37 @@
+# Generated by Django 3.0.8 on 2020-07-01 13:17
+
+from django.conf import settings
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('common', '0007_auto_20200116_1610'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='attachment',
+            name='url',
+            field=models.URLField(blank=True, max_length=500, null=True),
+        ),
+        migrations.CreateModel(
+            name='PluginConfiguration',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('code', models.CharField(max_length=100)),
+                ('conf', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
+                ('enabled', models.BooleanField(default=False)),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plugins', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'unique_together': {('user', 'code')},
+            },
+        ),
+    ]
diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py
index 1a31b2dcda00b9bd735cb2860e6deef2df79ca9e..e0bd216198f799d517c7820983ce464d0343ff74 100644
--- a/api/funkwhale_api/common/models.py
+++ b/api/funkwhale_api/common/models.py
@@ -363,3 +363,24 @@ def remove_attached_content(sender, instance, **kwargs):
                 getattr(instance, field).delete()
             except Content.DoesNotExist:
                 pass
+
+
+class PluginConfiguration(models.Model):
+    """
+    Store plugin configuration in DB
+    """
+
+    code = models.CharField(max_length=100)
+    user = models.ForeignKey(
+        "users.User",
+        related_name="plugins",
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+    conf = JSONField(null=True, blank=True)
+    enabled = models.BooleanField(default=False)
+    creation_date = models.DateTimeField(default=timezone.now)
+
+    class Meta:
+        unique_together = ("user", "code")
diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py
index a6ee0c9261c711439baf52d6ad7c440b4cd4b2d5..a4818acd96325c7e069e5646e48cfe25ebc0658c 100644
--- a/api/funkwhale_api/common/views.py
+++ b/api/funkwhale_api/common/views.py
@@ -12,6 +12,8 @@ from rest_framework import response
 from rest_framework import views
 from rest_framework import viewsets
 
+from config import plugins
+
 from funkwhale_api.users.oauth import permissions as oauth_permissions
 
 from . import filters
@@ -210,3 +212,102 @@ class TextPreviewView(views.APIView):
             )
         }
         return response.Response(data, status=200)
+
+
+class PluginViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
+    required_scope = "plugins"
+    serializer_class = serializers.serializers.Serializer
+    queryset = models.PluginConfiguration.objects.none()
+
+    def list(self, request, *args, **kwargs):
+        user = request.user
+        user_plugins = [p for p in plugins._plugins.values() if p["user"] is True]
+
+        return response.Response(
+            [
+                plugins.serialize_plugin(p, confs=plugins.get_confs(user=user))
+                for p in user_plugins
+            ]
+        )
+
+    def retrieve(self, request, *args, **kwargs):
+        user = request.user
+        user_plugin = [
+            p
+            for p in plugins._plugins.values()
+            if p["user"] is True and p["name"] == kwargs["pk"]
+        ]
+        if not user_plugin:
+            return response.Response(status=404)
+
+        return response.Response(
+            plugins.serialize_plugin(user_plugin[0], confs=plugins.get_confs(user=user))
+        )
+
+    def post(self, request, *args, **kwargs):
+        return self.create(request, *args, **kwargs)
+
+    def create(self, request, *args, **kwargs):
+        user = request.user
+        confs = plugins.get_confs(user=user)
+
+        user_plugin = [
+            p
+            for p in plugins._plugins.values()
+            if p["user"] is True and p["name"] == kwargs["pk"]
+        ]
+        if kwargs["pk"] not in confs:
+            return response.Response(status=404)
+        plugins.set_conf(kwargs["pk"], request.data, user)
+        return response.Response(
+            plugins.serialize_plugin(user_plugin[0], confs=plugins.get_confs(user=user))
+        )
+
+    def delete(self, request, *args, **kwargs):
+        user = request.user
+        confs = plugins.get_confs(user=user)
+        if kwargs["pk"] not in confs:
+            return response.Response(status=404)
+
+        user.plugins.filter(code=kwargs["pk"]).delete()
+        return response.Response(status=204)
+
+    @action(detail=True, methods=["post"])
+    def enable(self, request, *args, **kwargs):
+        user = request.user
+        if kwargs["pk"] not in plugins._plugins:
+            return response.Response(status=404)
+        plugins.enable_conf(kwargs["pk"], True, user)
+        return response.Response({}, status=200)
+
+    @action(detail=True, methods=["post"])
+    def disable(self, request, *args, **kwargs):
+        user = request.user
+        if kwargs["pk"] not in plugins._plugins:
+            return response.Response(status=404)
+        plugins.enable_conf(kwargs["pk"], False, user)
+        return response.Response({}, status=200)
+
+    @action(detail=True, methods=["post"])
+    def scan(self, request, *args, **kwargs):
+        user = request.user
+        if kwargs["pk"] not in plugins._plugins:
+            return response.Response(status=404)
+        conf = plugins.get_conf(kwargs["pk"], user=user)
+
+        if not conf["enabled"]:
+            return response.Response(status=405)
+
+        library = request.user.actor.libraries.get(uuid=conf["conf"]["library"])
+        hook = [
+            hook
+            for p, hook in plugins._hooks.get(plugins.SCAN, [])
+            if p == kwargs["pk"]
+        ]
+
+        if not hook:
+            return response.Response(status=405)
+
+        hook[0](library=library, conf=conf["conf"])
+
+        return response.Response({}, status=200)
diff --git a/api/funkwhale_api/contrib/scrobbler/__init__.py b/api/funkwhale_api/contrib/scrobbler/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py b/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py
new file mode 100644
index 0000000000000000000000000000000000000000..b7a278f83b162f1c970e97a4ae90810142121ef5
--- /dev/null
+++ b/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py
@@ -0,0 +1,39 @@
+from config import plugins
+
+from .funkwhale_startup import PLUGIN
+
+from . import scrobbler
+
+# https://listenbrainz.org/lastfm-proxy
+DEFAULT_SCROBBLER_URL = "http://post.audioscrobbler.com"
+
+
+@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
+def forward_to_scrobblers(listening, conf, **kwargs):
+    if not conf:
+        raise plugins.Skip()
+
+    username = conf.get("username")
+    password = conf.get("password")
+    url = conf.get("url", DEFAULT_SCROBBLER_URL) or DEFAULT_SCROBBLER_URL
+    if username and password:
+        PLUGIN["logger"].info("Forwarding scrobbler to %s", url)
+        session = plugins.get_session()
+        session_key, now_playing_url, scrobble_url = scrobbler.handshake_v1(
+            session=session, url=url, username=username, password=password
+        )
+        scrobbler.submit_now_playing_v1(
+            session=session,
+            track=listening.track,
+            session_key=session_key,
+            now_playing_url=now_playing_url,
+        )
+        scrobbler.submit_scrobble_v1(
+            session=session,
+            track=listening.track,
+            scrobble_time=listening.creation_date,
+            session_key=session_key,
+            scrobble_url=scrobble_url,
+        )
+    else:
+        PLUGIN["logger"].debug("No scrobbler configuration for user, skipping")
diff --git a/api/funkwhale_api/contrib/scrobbler/funkwhale_startup.py b/api/funkwhale_api/contrib/scrobbler/funkwhale_startup.py
new file mode 100644
index 0000000000000000000000000000000000000000..517a1eadba5c780e7915f963e4a873ab0eced334
--- /dev/null
+++ b/api/funkwhale_api/contrib/scrobbler/funkwhale_startup.py
@@ -0,0 +1,27 @@
+from config import plugins
+
+PLUGIN = plugins.get_plugin_config(
+    name="scrobbler",
+    label="Scrobbler",
+    description="A plugin that enables scrobbling to ListenBrainz and Last.fm",
+    version="0.1",
+    user=True,
+    conf=[
+        {
+            "name": "url",
+            "type": "url",
+            "allow_null": True,
+            "allow_blank": True,
+            "required": False,
+            "label": "URL of the scrobbler service",
+            "help": (
+                "Suggested choices:\n\n"
+                "- LastFM (default if left empty): http://post.audioscrobbler.com\n"
+                "- ListenBrainz: http://proxy.listenbrainz.org/\n"
+                "- Libre.fm: http://turtle.libre.fm/"
+            ),
+        },
+        {"name": "username", "type": "text", "label": "Your scrobbler username"},
+        {"name": "password", "type": "password", "label": "Your scrobbler password"},
+    ],
+)
diff --git a/api/funkwhale_api/contrib/scrobbler/scrobbler.py b/api/funkwhale_api/contrib/scrobbler/scrobbler.py
new file mode 100644
index 0000000000000000000000000000000000000000..3cf82be260de67bfe5d7a2d3cd0efa616ed532da
--- /dev/null
+++ b/api/funkwhale_api/contrib/scrobbler/scrobbler.py
@@ -0,0 +1,98 @@
+import hashlib
+import time
+
+
+# https://github.com/jlieth/legacy-scrobbler
+from .funkwhale_startup import PLUGIN
+
+
+class ScrobblerException(Exception):
+    pass
+
+
+def handshake_v1(session, url, username, password):
+    timestamp = str(int(time.time())).encode("utf-8")
+    password_hash = hashlib.md5(password.encode("utf-8")).hexdigest()
+    auth = hashlib.md5(password_hash.encode("utf-8") + timestamp).hexdigest()
+    params = {
+        "hs": "true",
+        "p": "1.2",
+        "c": PLUGIN["name"],
+        "v": PLUGIN["version"],
+        "u": username,
+        "t": timestamp,
+        "a": auth,
+    }
+
+    PLUGIN["logger"].debug(
+        "Performing scrobbler handshake for username %s at %s", username, url
+    )
+    handshake_response = session.get(url, params=params)
+    # process response
+    result = handshake_response.text.split("\n")
+    if len(result) >= 4 and result[0] == "OK":
+        session_key = result[1]
+        nowplaying_url = result[2]
+        scrobble_url = result[3]
+    elif result[0] == "BANNED":
+        raise ScrobblerException("BANNED")
+    elif result[0] == "BADAUTH":
+        raise ScrobblerException("BADAUTH")
+    elif result[0] == "BADTIME":
+        raise ScrobblerException("BADTIME")
+    else:
+        raise ScrobblerException(handshake_response.text)
+
+    PLUGIN["logger"].debug("Handshake successful, scrobble url: %s", scrobble_url)
+    return session_key, nowplaying_url, scrobble_url
+
+
+def submit_scrobble_v1(session, scrobble_time, track, session_key, scrobble_url):
+    payload = get_scrobble_payload(track, scrobble_time)
+    PLUGIN["logger"].debug("Sending scrobble with payload %s", payload)
+    payload["s"] = session_key
+    response = session.post(scrobble_url, payload)
+    response.raise_for_status()
+    if response.text.startswith("OK"):
+        return
+    elif response.text.startswith("BADSESSION"):
+        raise ScrobblerException("Remote server says the session is invalid")
+    else:
+        raise ScrobblerException(response.text)
+
+    PLUGIN["logger"].debug("Scrobble successfull!")
+
+
+def submit_now_playing_v1(session, track, session_key, now_playing_url):
+    payload = get_scrobble_payload(track, date=None, suffix="")
+    PLUGIN["logger"].debug("Sending now playing with payload %s", payload)
+    payload["s"] = session_key
+    response = session.post(now_playing_url, payload)
+    response.raise_for_status()
+    if response.text.startswith("OK"):
+        return
+    elif response.text.startswith("BADSESSION"):
+        raise ScrobblerException("Remote server says the session is invalid")
+    else:
+        raise ScrobblerException(response.text)
+
+    PLUGIN["logger"].debug("Now playing successfull!")
+
+
+def get_scrobble_payload(track, date, suffix="[0]"):
+    """
+    Documentation available at https://web.archive.org/web/20190531021725/https://www.last.fm/api/submissions
+    """
+    upload = track.uploads.filter(duration__gte=0).first()
+    data = {
+        "a{}".format(suffix): track.artist.name,
+        "t{}".format(suffix): track.title,
+        "l{}".format(suffix): upload.duration if upload else 0,
+        "b{}".format(suffix): (track.album.title if track.album else "") or "",
+        "n{}".format(suffix): track.position or "",
+        "m{}".format(suffix): str(track.mbid) or "",
+        "o{}".format(suffix): "P",  # Source: P = chosen by user
+    }
+    if date:
+        data["i{}".format(suffix)] = int(date.timestamp())
+    return data
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index 56afadf4046c2ca984605b1986ea6920d400d1de..a14917fc1b0a7a6c258dcf45506bb3d1cf92c669 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -2,6 +2,8 @@ from rest_framework import mixins, viewsets
 
 from django.db.models import Prefetch
 
+from config import plugins
+
 from funkwhale_api.activity import record
 from funkwhale_api.common import fields, permissions
 from funkwhale_api.music.models import Track
@@ -39,6 +41,11 @@ class ListeningViewSet(
 
     def perform_create(self, serializer):
         r = super().perform_create(serializer)
+        plugins.trigger_hook(
+            plugins.LISTENING_CREATED,
+            listening=serializer.instance,
+            confs=plugins.get_confs(self.request.user),
+        )
         record.send(serializer.instance)
         return r
 
diff --git a/api/funkwhale_api/music/management/commands/import_files.py b/api/funkwhale_api/music/management/commands/import_files.py
index 6643d04c67c5ec6f502258b766ae795de6050db3..0d44af49c30bef40e997af03fb70be45635ebf06 100644
--- a/api/funkwhale_api/music/management/commands/import_files.py
+++ b/api/funkwhale_api/music/management/commands/import_files.py
@@ -655,9 +655,7 @@ def handle_modified(event, stdout, library, in_place, **kwargs):
                 and to_update.track.attributed_to != library.actor
             ):
                 stdout.write(
-                    "  Cannot update track metadata, track belongs to someone else".format(
-                        to_update.pk
-                    )
+                    "  Cannot update track metadata, track belongs to someone else"
                 )
                 return
             else:
@@ -777,9 +775,7 @@ def check_upload(stdout, upload):
         )
         if upload.library.actor_id != upload.track.attributed_to_id:
             stdout.write(
-                "  Cannot update track metadata, track belongs to someone else".format(
-                    upload.pk
-                )
+                "  Cannot update track metadata, track belongs to someone else"
             )
         else:
             track = models.Track.objects.select_related("artist", "album__artist").get(
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 8942321b6d449dc495c75e147da89da9f6a7d3ca..c6da1a1157d08bbec0bb65ee6df75eb22c80d688 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -103,7 +103,9 @@ class UserQuerySet(models.QuerySet):
             user=models.OuterRef("id"), primary=True
         ).values("verified")[:1]
         subquery = models.Subquery(verified_emails)
-        return qs.annotate(has_verified_primary_email=subquery)
+        return qs.annotate(has_verified_primary_email=subquery).prefetch_related(
+            "plugins"
+        )
 
 
 class UserManager(BaseUserManager):
diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py
index 23958ccade9ff8238628ca0f328092753f0a279a..f5390eb0452bb594e466196a5c664a94deddcc07 100644
--- a/api/funkwhale_api/users/oauth/scopes.py
+++ b/api/funkwhale_api/users/oauth/scopes.py
@@ -23,6 +23,7 @@ BASE_SCOPES = [
     Scope("notifications", "Access personal notifications"),
     Scope("security", "Access security settings"),
     Scope("reports", "Access reports"),
+    Scope("plugins", "Access plugins"),
     # Privileged scopes that require specific user permissions
     Scope("instance:settings", "Access instance settings"),
     Scope("instance:users", "Access local user accounts"),
@@ -81,7 +82,12 @@ COMMON_SCOPES = ANONYMOUS_SCOPES | {
     "write:listenings",
 }
 
-LOGGED_IN_SCOPES = COMMON_SCOPES | {"read:security", "write:security"}
+LOGGED_IN_SCOPES = COMMON_SCOPES | {
+    "read:security",
+    "write:security",
+    "read:plugins",
+    "write:plugins",
+}
 
 # We don't allow admin access for oauth apps yet
 OAUTH_APP_SCOPES = COMMON_SCOPES
diff --git a/api/setup.cfg b/api/setup.cfg
index 44718f38853bc10342997574bc43af1a652d1b11..724d1c87f2ae04723d7cf5a46cd1f05b27ba8ccc 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -1,7 +1,7 @@
 [flake8]
 max-line-length = 120
 exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,tests/data,tests/music/conftest.py
-ignore = F405,W503,E203
+ignore = F405,W503,E203,E741
 
 [isort]
 skip_glob = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
@@ -35,3 +35,4 @@ env =
     EXTERNAL_MEDIA_PROXY_ENABLED=true
     DISABLE_PASSWORD_VALIDATORS=false
     DISABLE_PASSWORD_VALIDATORS=false
+    FUNKWHALE_PLUGINS=
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index bf4f62bb9e6c1ac8468f7fc4edaea72920b32a3c..92f9b9cebb9b9c3c820a0aa717c8e11c8d633a2c 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -67,11 +67,11 @@ def test_domain_create(superuser_api_client, mocker):
         "funkwhale_api.federation.tasks.update_domain_nodeinfo"
     )
     url = reverse("api:v1:manage:federation:domains-list")
-    response = superuser_api_client.post(url, {"name": "test.federation"})
+    response = superuser_api_client.post(url, {"name": "test.domain"})
 
     assert response.status_code == 201
-    assert federation_models.Domain.objects.filter(pk="test.federation").exists()
-    update_domain_nodeinfo.assert_called_once_with(domain_name="test.federation")
+    assert federation_models.Domain.objects.filter(pk="test.domain").exists()
+    update_domain_nodeinfo.assert_called_once_with(domain_name="test.domain")
 
 
 def test_domain_update_allowed(superuser_api_client, factories):
@@ -85,6 +85,8 @@ def test_domain_update_allowed(superuser_api_client, factories):
 
 
 def test_domain_update_cannot_change_name(superuser_api_client, factories):
+    superuser_api_client.user.create_actor()
+
     domain = factories["federation.Domain"]()
     old_name = domain.name
     url = reverse("api:v1:manage:federation:domains-detail", kwargs={"pk": old_name})
@@ -96,7 +98,9 @@ def test_domain_update_cannot_change_name(superuser_api_client, factories):
     assert domain.name == old_name
     # changing the pk of a model and saving results in a new DB entry in django,
     # so we check that no other entry was created
-    assert domain.__class__.objects.count() == 1
+    assert (
+        domain.__class__.objects.count() == 2
+    )  # one for pod domain, and the other one
 
 
 def test_domain_nodeinfo(factories, superuser_api_client, mocker):
@@ -131,8 +135,8 @@ def test_actor_list(factories, superuser_api_client, settings):
 
     assert response.status_code == 200
 
-    assert response.data["count"] == 1
-    assert response.data["results"][0]["id"] == actor.id
+    assert response.data["count"] == 2
+    assert response.data["results"][1]["id"] == actor.id
 
 
 def test_actor_detail(factories, superuser_api_client):
diff --git a/api/tests/plugins/test_plugins.py b/api/tests/plugins/test_plugins.py
new file mode 100644
index 0000000000000000000000000000000000000000..ab06fe1ba10bb9bded0df190c7957460e8780c36
--- /dev/null
+++ b/api/tests/plugins/test_plugins.py
@@ -0,0 +1,424 @@
+import os
+import sys
+
+import pytest
+
+from django.urls import reverse
+
+from rest_framework import serializers
+
+from funkwhale_api.common import models
+from config import plugins
+
+
+@pytest.fixture(autouse=True)
+def _plugins():
+    plugins._filters.clear()
+    plugins._hooks.clear()
+    plugins._plugins.clear()
+    yield
+    plugins._filters.clear()
+    plugins._hooks.clear()
+    plugins._plugins.clear()
+
+
+def test_register_filter():
+    filters = {}
+    plugin_config = plugins.get_plugin_config("test", {})
+
+    def handler(value, conf):
+        return value + 1
+
+    plugins.register_filter("test_filter", plugin_config, filters)(handler)
+    plugins.register_filter("test_filter", plugin_config, filters)(handler)
+
+    assert len(filters["test_filter"]) == 2
+    assert plugins.trigger_filter("test_filter", 1, confs={}, registry=filters) == 3
+
+
+def test_register_hook(mocker):
+    hooks = {}
+    plugin_config = plugins.get_plugin_config("test", {})
+    mock = mocker.Mock()
+
+    def handler(conf):
+        mock()
+
+    plugins.register_hook("test_hook", plugin_config, hooks)(handler)
+    plugins.register_hook("test_hook", plugin_config, hooks)(handler)
+    plugins.trigger_hook("test_hook", confs={}, registry=hooks)
+    assert mock.call_count == 2
+    assert len(hooks["test_hook"]) == 2
+
+
+def test_get_plugin_conf():
+    _plugins = {}
+    plugin_config = plugins.get_plugin_config(
+        "test", description="Hello", registry=_plugins
+    )
+    assert plugin_config["name"] == "test"
+    assert plugin_config["description"] == "Hello"
+    assert plugin_config["user"] is False
+    assert _plugins == {
+        "test": plugin_config,
+    }
+
+
+def test_set_plugin_conf_validates():
+    _plugins = {}
+    plugins.get_plugin_config(
+        "test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
+    )
+
+    with pytest.raises(serializers.ValidationError):
+        plugins.set_conf("test", {"foo": "noop"}, registry=_plugins)
+
+
+def test_set_plugin_conf_valid():
+    _plugins = {}
+    plugins.get_plugin_config(
+        "test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
+    )
+    plugins.set_conf("test", {"foo": True}, registry=_plugins)
+
+    conf = models.PluginConfiguration.objects.latest("id")
+    assert conf.code == "test"
+    assert conf.conf == {"foo": True}
+    assert conf.user is None
+
+
+def test_set_plugin_conf_valid_user(factories):
+    user = factories["users.User"]()
+    _plugins = {}
+    plugins.get_plugin_config(
+        "test", registry=_plugins, conf=[{"name": "foo", "type": "boolean"}]
+    )
+
+    plugins.set_conf("test", {"foo": True}, user=user, registry=_plugins)
+
+    conf = models.PluginConfiguration.objects.latest("id")
+    assert conf.code == "test"
+    assert conf.conf == {"foo": True}
+    assert conf.user == user
+
+
+def test_get_confs(factories):
+    plugins.get_plugin_config("test1")
+    plugins.get_plugin_config("test2")
+    factories["common.PluginConfiguration"](code="test1", conf={"hello": "world"})
+    factories["common.PluginConfiguration"](code="test2", conf={"foo": "bar"})
+
+    assert plugins.get_confs() == {
+        "test1": {"conf": {"hello": "world"}, "enabled": False},
+        "test2": {"conf": {"foo": "bar"}, "enabled": False},
+    }
+
+
+def test_get_confs_user(factories):
+    plugins.get_plugin_config("test1")
+    plugins.get_plugin_config("test2")
+    plugins.get_plugin_config("test3")
+    user1 = factories["users.User"]()
+    user2 = factories["users.User"]()
+    factories["common.PluginConfiguration"](code="test1", conf={"hello": "world"})
+    factories["common.PluginConfiguration"](code="test2", conf={"foo": "bar"})
+    factories["common.PluginConfiguration"](
+        code="test3", conf={"user": True}, user=user1
+    )
+    factories["common.PluginConfiguration"](
+        code="test4", conf={"user": False}, user=user2
+    )
+
+    assert plugins.get_confs(user=user1) == {
+        "test1": {"conf": {"hello": "world"}, "enabled": False},
+        "test2": {"conf": {"foo": "bar"}, "enabled": False},
+        "test3": {"conf": {"user": True}, "enabled": False},
+    }
+
+
+def test_filter_is_called_with_plugin_conf(mocker, factories):
+    plugins.get_plugin_config("test1",)
+    plugins.get_plugin_config("test2",)
+    factories["common.PluginConfiguration"](code="test1", enabled=True)
+    factories["common.PluginConfiguration"](
+        code="test2", conf={"foo": "baz"}, enabled=True
+    )
+    confs = plugins.get_confs()
+    filters = {}
+    plugin_config1 = plugins.get_plugin_config("test1", {})
+    plugin_config2 = plugins.get_plugin_config("test2", {})
+
+    handler1 = mocker.Mock()
+    handler2 = mocker.Mock()
+
+    plugins.register_filter("test_filter", plugin_config1, filters)(handler1)
+    plugins.register_filter("test_filter", plugin_config2, filters)(handler2)
+
+    plugins.trigger_filter("test_filter", 1, confs=confs, registry=filters)
+
+    handler1.assert_called_once_with(1, conf=confs["test1"])
+    handler2.assert_called_once_with(handler1.return_value, conf=confs["test2"])
+
+
+def test_get_serializer_from_conf_template():
+    template = [
+        {
+            "name": "enabled",
+            "type": "boolean",
+            "default": True,
+            "label": "Enable plugin",
+        },
+        {
+            "name": "api_url",
+            "type": "url",
+            "label": "URL of the scrobbler API",
+            "validator": lambda self, v: v + "/test",
+        },
+    ]
+
+    serializer_class = plugins.get_serializer_from_conf_template(template)
+
+    data = {
+        "enabled": True,
+        "api_url": "http://hello.world",
+    }
+
+    serializer = serializer_class(data=data)
+    assert serializer.is_valid(raise_exception=True) is True
+    assert serializer.validated_data == {
+        "enabled": True,
+        "api_url": "http://hello.world/test",
+    }
+
+
+def test_serialize_plugin():
+    plugin = plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+    )
+
+    expected = {
+        "name": "test_plugin",
+        "enabled": False,
+        "description": "Hello world",
+        "conf": [{"name": "foo", "type": "boolean"}],
+        "user": False,
+        "source": False,
+        "label": "test_plugin",
+        "values": None,
+    }
+
+    assert plugins.serialize_plugin(plugin, plugins.get_confs()) == expected
+
+
+def test_serialize_plugin_user(factories):
+    user = factories["users.User"]()
+    plugin = plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+
+    expected = {
+        "name": "test_plugin",
+        "enabled": False,
+        "description": "Hello world",
+        "conf": [{"name": "foo", "type": "boolean"}],
+        "user": True,
+        "source": False,
+        "label": "test_plugin",
+        "values": None,
+    }
+
+    assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
+
+
+def test_serialize_plugin_user_enabled(factories):
+    user = factories["users.User"]()
+    plugin = plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+
+    factories["common.PluginConfiguration"](
+        code="test_plugin", user=user, enabled=True, conf={"foo": "bar"}
+    )
+    expected = {
+        "name": "test_plugin",
+        "enabled": True,
+        "description": "Hello world",
+        "conf": [{"name": "foo", "type": "boolean"}],
+        "user": True,
+        "source": False,
+        "label": "test_plugin",
+        "values": {"foo": "bar"},
+    }
+
+    assert plugins.serialize_plugin(plugin, plugins.get_confs(user)) == expected
+
+
+def test_can_list_user_plugins(logged_in_api_client):
+    plugin = plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+    plugins.get_plugin_config(name="test_plugin2", user=False)
+    url = reverse("api:v1:plugins-list")
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == [
+        plugins.serialize_plugin(plugin, plugins.get_confs(logged_in_api_client.user))
+    ]
+
+
+def test_can_retrieve_user_plugin(logged_in_api_client):
+    plugin = plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+    plugins.get_plugin_config(name="test_plugin2", user=False)
+    url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == plugins.serialize_plugin(
+        plugin, plugins.get_confs(logged_in_api_client.user)
+    )
+
+
+def test_can_update_user_plugin(logged_in_api_client):
+    plugin = plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+    plugins.get_plugin_config(name="test_plugin2", user=False)
+    url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
+    response = logged_in_api_client.post(url, {"foo": True})
+    assert response.status_code == 200
+    assert logged_in_api_client.user.plugins.latest("id").conf == {"foo": True}
+    assert response.data == plugins.serialize_plugin(
+        plugin, plugins.get_confs(logged_in_api_client.user)
+    )
+
+
+def test_can_destroy_user_plugin(logged_in_api_client):
+    plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+    plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
+    plugins.get_plugin_config(name="test_plugin2", user=False)
+    url = reverse("api:v1:plugins-detail", kwargs={"pk": "test_plugin"})
+    response = logged_in_api_client.delete(url, {"enabled": True})
+    assert response.status_code == 204
+
+    with pytest.raises(models.PluginConfiguration.DoesNotExist):
+        assert logged_in_api_client.user.plugins.latest("id")
+
+
+def test_can_enable_user_plugin(logged_in_api_client):
+    plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+    plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
+    url = reverse("api:v1:plugins-enable", kwargs={"pk": "test_plugin"})
+    response = logged_in_api_client.post(url)
+    assert response.status_code == 200
+
+    assert logged_in_api_client.user.plugins.latest("id").enabled is True
+
+
+def test_can_disable_user_plugin(logged_in_api_client):
+    plugins.get_plugin_config(
+        name="test_plugin",
+        description="Hello world",
+        conf=[{"name": "foo", "type": "boolean"}],
+        user=True,
+    )
+    plugins.set_conf("test_plugin", {"foo": True}, user=logged_in_api_client.user)
+    url = reverse("api:v1:plugins-disable", kwargs={"pk": "test_plugin"})
+    response = logged_in_api_client.post(url)
+    assert response.status_code == 200
+
+    assert logged_in_api_client.user.plugins.latest("id").enabled is False
+
+
+def test_can_install_dependencies(mocker):
+    dependencies = ["depa==12", "depb"]
+    check_call = mocker.patch("subprocess.check_call")
+    expected = [
+        os.path.join(os.path.dirname(sys.executable), "pip"),
+        "install",
+    ] + dependencies
+    plugins.install_dependencies(dependencies)
+    check_call.assert_called_once_with(expected)
+
+
+def test_set_plugin_source_conf_invalid(factories):
+    user = factories["users.User"]()
+    _plugins = {}
+    plugins.get_plugin_config(
+        "test",
+        source=True,
+        registry=_plugins,
+        conf=[{"name": "foo", "type": "boolean"}],
+    )
+    with pytest.raises(serializers.ValidationError):
+        plugins.set_conf("test", {"foo": True}, user=user, registry=_plugins)
+
+
+def test_set_plugin_source_conf_valid(factories):
+    library = factories["music.Library"](actor__local=True)
+    _plugins = {}
+    plugins.get_plugin_config(
+        "test",
+        source=True,
+        registry=_plugins,
+        conf=[{"name": "foo", "type": "boolean"}],
+    )
+    plugins.set_conf(
+        "test",
+        {"foo": True, "library": library.uuid},
+        user=library.actor.user,
+        registry=_plugins,
+    )
+    conf = models.PluginConfiguration.objects.latest("id")
+    assert conf.code == "test"
+    assert conf.conf == {"foo": True, "library": str(library.uuid)}
+    assert conf.user == library.actor.user
+
+
+def test_can_trigger_scan(logged_in_api_client, mocker, factories):
+    library = factories["music.Library"](actor=logged_in_api_client.user.create_actor())
+    plugin = plugins.get_plugin_config(
+        name="test_plugin", description="Hello world", conf=[], source=True,
+    )
+    handler = mocker.Mock()
+    plugins.register_hook(plugins.SCAN, plugin)(handler)
+    plugins.set_conf(
+        "test_plugin", {"library": library.uuid}, user=logged_in_api_client.user
+    )
+    url = reverse("api:v1:plugins-scan", kwargs={"pk": "test_plugin"})
+    plugins.enable_conf("test_plugin", True, logged_in_api_client.user)
+    response = logged_in_api_client.post(url)
+    assert response.status_code == 200
+
+    handler.assert_called_once_with(
+        library=library, conf={"library": str(library.uuid)}
+    )
diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py
index 7261ac6b17a46734948381051bddc4ef4d157cbc..e55b78319e7c3160b74b6c699674f0f8f35715c3 100644
--- a/api/tests/users/oauth/test_scopes.py
+++ b/api/tests/users/oauth/test_scopes.py
@@ -34,6 +34,9 @@ from funkwhale_api.users.oauth import scopes
                 "write:listenings",
                 "read:security",
                 "write:security",
+                "write:listenings",
+                "read:plugins",
+                "write:plugins",
                 "read:instance:policies",
                 "write:instance:policies",
                 "read:instance:accounts",
@@ -85,6 +88,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:listenings",
                 "read:security",
                 "write:security",
+                "read:plugins",
+                "write:plugins",
                 "read:instance:policies",
                 "write:instance:policies",
                 "read:instance:accounts",
@@ -132,6 +137,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:listenings",
                 "read:security",
                 "write:security",
+                "read:plugins",
+                "write:plugins",
                 "read:instance:policies",
                 "write:instance:policies",
                 "read:instance:accounts",
@@ -173,6 +180,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:listenings",
                 "read:security",
                 "write:security",
+                "read:plugins",
+                "write:plugins",
             },
         ),
     ],
diff --git a/docs/conf.py b/docs/conf.py
index ae278c5e27eb437e8944095df9e4959672e335e4..5ac3127be72e35e02342040ce84546609b6de215 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -23,6 +23,7 @@ import datetime
 from shutil import copyfile
 
 sys.path.insert(0, os.path.abspath("../api"))
+sys.path.insert(0, os.path.abspath("../api/config"))
 
 import funkwhale_api  # NOQA
 
@@ -30,9 +31,9 @@ FUNKWHALE_CONFIG = {
     "FUNKWHALE_URL": "mypod.funkwhale",
     "FUNKWHAL_PROTOCOL": "https",
     "DATABASE_URL": "postgres://localhost:5432/db",
-    "AWS_ACCESS_KEY_ID": 'my_access_key',
-    "AWS_SECRET_ACCESS_KEY": 'my_secret_key',
-    "AWS_STORAGE_BUCKET_NAME": 'my_bucket',
+    "AWS_ACCESS_KEY_ID": "my_access_key",
+    "AWS_SECRET_ACCESS_KEY": "my_secret_key",
+    "AWS_STORAGE_BUCKET_NAME": "my_bucket",
 }
 for key, value in FUNKWHALE_CONFIG.items():
     os.environ[key] = value
@@ -46,7 +47,7 @@ for key, value in FUNKWHALE_CONFIG.items():
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = ["sphinx.ext.graphviz", "sphinx.ext.autodoc"]
-autodoc_mock_imports = ["celery", "django_auth_ldap", "ldap"]
+autodoc_mock_imports = ["celery", "django_auth_ldap", "ldap", "persisting_theory", "rest_framework"]
 add_module_names = False
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ["_templates"]
diff --git a/docs/developers/index.rst b/docs/developers/index.rst
index 966cac3afd0d2867c7899bb34160e2a893ad8f9a..f214c78196edc6f14c7b13b75d630d56d53d5af9 100644
--- a/docs/developers/index.rst
+++ b/docs/developers/index.rst
@@ -13,5 +13,6 @@ Reference
    architecture
    ../api
    ./authentication
+   ./plugins
    ../federation/index
    subsonic
diff --git a/docs/developers/plugins.rst b/docs/developers/plugins.rst
new file mode 100644
index 0000000000000000000000000000000000000000..f9f537479d4778eed41630f505e7ce20b89ff487
--- /dev/null
+++ b/docs/developers/plugins.rst
@@ -0,0 +1,165 @@
+Funkwhale plugins
+=================
+
+Starting with Funkwhale 1.0, it is now possible to implement new features
+via plugins. 
+
+Some plugins are maintained by the Funkwhale team (e.g. this is the case of the ``scrobbler`` plugin),
+or by third-parties.
+
+Installing a plugin
+-------------------
+
+To install a plugin, ensure its directory is present in the ``FUNKWHALE_PLUGINS_PATH`` directory.
+
+Then, add its name to the ``FUNKWHALE_PLUGINS`` environment variable, like this::
+
+    FUNKWHALE_PLUGINS=myplugin,anotherplugin
+
+We provide a command to make it easy to install third-party plugins::
+
+    python manage.py fw plugins install https://pluginurl.zip
+
+.. note::
+
+    If you use the command, you will still need to append the plugin name to ``FUNKWHALE_PLUGINS``
+
+
+Types of plugins
+----------------
+
+There are two types of plugins:
+
+1. Plugins that are accessible to end-users, a.k.a. user-level plugins. This is the case of our Scrobbler plugin
+2. Pod-level plugins that are configured by pod admins and are not tied to a particular user
+
+Additionally, user-level plugins can be regular plugins or source plugins. A source plugin provides
+a way to import files from a third-party service, e.g via webdav, FTP or something similar.
+
+Hooks and filters
+-----------------
+
+Funkwhale includes two kind of entrypoints for plugins to use: hooks and filters. B
+
+Hooks should be used when you want to react to some change. For instance, the ``LISTENING_CREATED`` hook
+notify each registered callback that a listening was created. Our ``scrobbler`` plugin has a callback
+registered to this hook, so that it can notify Last.fm properly:
+
+.. code-block:: python
+
+    from config import plugins
+    from .funkwhale_startup import PLUGIN
+
+    @plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
+    def notify_lastfm(listening, conf, **kwargs):
+        # do something
+
+Filters work slightly differently, and expect callbacks to return a value that will be used by Funkwhale.
+
+For instance, the ``PLUGINS_DEPENDENCIES`` filter can be used as a way to install additional dependencies needed by your plugin:
+
+
+.. code-block:: python
+
+    # funkwhale_startup.py
+    # ...
+    from config import plugins
+
+    @plugins.register_filter(plugins.PLUGINS_DEPENDENCIES, PLUGIN)
+    def dependencies(dependencies, **kwargs):
+        return dependencies + ["django_prometheus"]
+
+To sum it up, hooks are used when you need to react to something, and filters when you need to alter something.
+
+Writing a plugin
+----------------
+
+Regardless of the type of plugin you want to write, lots of concepts are similar.
+
+First, a plugin need three files:
+
+- a ``__init__.py`` file, since it's a Python package
+- a ``funkwhale_startup.py`` file, that is loaded during Funkwhale initialization
+- a ``funkwhale_ready.py`` file, that is loaded when Funkwhale is configured and ready 
+
+So your plugin directory should look like this::
+
+    myplugin
+    ├── funkwhale_ready.py
+    ├── funkwhale_startup.py
+    └── __init__.py
+
+Now, let's write our plugin!
+
+``funkwhale_startup.py`` is where you declare your plugin and it's configuration options:
+
+.. code-block:: python
+
+    # funkwhale_startup.py
+    from config import plugins
+
+    PLUGIN = plugins.get_plugin_config(
+        name="myplugin",
+        label="My Plugin",
+        description="An example plugin that greets you",
+        version="0.1",
+        # here, we write a user-level plugin
+        user=True,
+        conf=[
+            # this configuration options are editable by each user
+            {"name": "greeting", "type": "text", "label": "Greeting", "default": "Hello"},
+        ],
+    )
+
+Now that our plugin is declared and configured, let's implement actual functionality in ``funkwhale_ready.py``:
+
+.. code-block:: python
+
+    # funkwhale_ready.py
+    from django.urls import path
+    from rest_framework import response
+    from rest_framework import views
+
+    from config import plugins
+
+    from .funkwhale_startup import PLUGIN
+
+    # Our greeting view, where the magic happens
+    class GreetingView(views.APIView):
+        permission_classes = []
+        def get(self, request, *args, **kwargs):
+            # retrieve plugin configuration for the current user
+            conf = plugins.get_conf(PLUGIN["name"], request.user)
+            if not conf["enabled"]:
+                # plugin is disabled for this user
+                return response.Response(status=405)
+            greeting = conf["conf"]["greeting"]
+            data = {
+                "greeting": "{} {}!".format(greeting, request.user.username)
+            }
+            return response.Response(data)
+
+    # Ensure our view is known by Django and available at /greeting
+    @plugins.register_filter(plugins.URLS, PLUGIN)
+    def register_view(urls, **kwargs):
+        return urls + [
+            path('greeting', GreetingView.as_view())
+        ]
+
+And that's pretty much it. Now, login, visit https://yourpod.domain/settings/plugins, set a value in the ``greeting`` field and enable the plugin.
+
+After that, you should be greeted properly if you go to https://yourpod.domain/greeting.
+
+Hooks reference
+---------------
+
+.. autodata:: config.plugins.LISTENING_CREATED
+
+Filters reference
+-----------------
+
+.. autodata:: config.plugins.PLUGINS_DEPENDENCIES
+.. autodata:: config.plugins.PLUGINS_APPS
+.. autodata:: config.plugins.PLUGINSMIDDLEWARES_BEFORE_DEPENDENCIES
+.. autodata:: config.plugins.MIDDLEWARES_AFTER
+.. autodata:: config.plugins.URLS
diff --git a/front/scripts/fix-fomantic-css.py b/front/scripts/fix-fomantic-css.py
index 0e9f51771089653979272fe8635103b3c4eecbd5..692383f596dc0800b33984e6dc8ad2f3f9b63756 100755
--- a/front/scripts/fix-fomantic-css.py
+++ b/front/scripts/fix-fomantic-css.py
@@ -79,6 +79,7 @@ GLOBAL_REPLACES = [
     ("#ff4335", "var(--danger-focus-color)"),
 ]
 
+
 def discard_unused_icons(rule):
     """
     Add an icon to this list if you want to use it in the app.
@@ -890,7 +891,9 @@ def replace_vars(source, dest):
 
 
 if __name__ == "__main__":
-    parser = argparse.ArgumentParser(description="Replace hardcoded values by CSS vars and strip unused rules")
+    parser = argparse.ArgumentParser(
+        description="Replace hardcoded values by CSS vars and strip unused rules"
+    )
     parser.add_argument(
         "source", help="Source path of the fomantic-ui-less distribution to fix"
     )
diff --git a/front/src/components/auth/Plugin.vue b/front/src/components/auth/Plugin.vue
new file mode 100644
index 0000000000000000000000000000000000000000..315af0449a3b46f1fabd24bf8952d42d1d367f6a
--- /dev/null
+++ b/front/src/components/auth/Plugin.vue
@@ -0,0 +1,113 @@
+<template>
+  <form :class="['ui form', {loading: isLoading}]" @submit.prevent="submit">  
+    <h3>{{ plugin.label }}</h3>
+    <div v-if="plugin.description" v-html="markdown.makeHtml(plugin.description)"></div>
+    <div class="ui clearing hidden divider"></div> 
+    <div v-if="errors.length > 0" role="alert" class="ui negative message">
+        <div class="header"><translate translate-context="Content/*/Error message.Title">Error while saving plugin</translate></div>
+        <ul class="list">
+          <li v-for="error in errors">{{ error }}</li>
+        </ul>
+      </div>
+    <div class="field">
+      <div class="ui toggle checkbox">
+        <input :id="`${plugin.name}-enabled`" type="checkbox" v-model="enabled" />
+        <label :for="`${plugin.name}-enabled`"><translate translate-context="*/*/*">Enabled</translate></label>
+      </div>
+    </div>
+    <div class="ui clearing hidden divider"></div> 
+    <div v-if="plugin.source" class="field">
+      <label for="plugin-library"><translate translate-context="*/*/*/Noun">Library</translate></label>
+      <select id="plugin-library" v-model="values['library']">
+        <option :value="l.uuid" v-for="l in libraries" :key="l.uuid">{{ l.name }}</option>
+      </select>
+      <div>
+        <translate translate-context="*/*/Paragraph/Noun">Library where files should be imported.</translate>
+      </div>
+    </div>
+    <template v-if="plugin.conf && plugin.conf.length > 0" v-for="field in plugin.conf">
+      <div v-if="field.type === 'text'" class="field">
+        <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
+        <input :id="`plugin-${field.name}`" type="text" v-model="values[field.name]">
+        <div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
+      </div>
+      <div v-if="field.type === 'long_text'" class="field">
+        <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
+        <textarea :id="`plugin-${field.name}`" type="text" v-model="values[field.name]" rows="5" />
+        <div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
+      </div>
+      <div v-if="field.type === 'url'" class="field">
+        <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
+        <input :id="`plugin-${field.name}`" type="url" v-model="values[field.name]">
+        <div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
+      </div>
+      </div>
+      <div v-if="field.type === 'password'" class="field">
+        <label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
+        <input :id="`plugin-${field.name}`" type="password" v-model="values[field.name]">
+        <div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
+      </div>
+    </template>
+    <button
+      type="submit"
+      :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
+        <translate translate-context="Content/*/Button.Label/Verb">Save</translate>
+    </button>
+    <button
+      type="scan"
+      v-if="plugin.source"
+      @click.prevent="submitAndScan"
+      :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
+        <translate translate-context="Content/*/Button.Label/Verb">Scan</translate>
+    </button>
+    <div class="ui clearing hidden divider"></div> 
+  </form>
+</template>
+
+<script>
+import axios from "axios"
+import lodash from '@/lodash'
+import showdown from 'showdown'
+export default {
+  props: ['plugin', "libraries"],
+  data () {
+    return {
+      markdown: new showdown.Converter(),
+      isLoading: false,
+      enabled: this.plugin.enabled,
+      values: lodash.clone(this.plugin.values || {}),
+      errors: [],
+    }
+  },
+  methods: {
+    async submit () {
+      this.isLoading = true
+      this.errors = []
+      let url = `plugins/${this.plugin.name}`
+      let enableUrl = this.enabled ? `${url}/enable` : `${url}/enable`
+      await axios.post(enableUrl)
+      try {
+        await axios.post(url, this.values)
+      } catch (e) {
+        this.errors = e.backendErrors
+      }
+      this.isLoading = false
+    },
+    async scan () {
+      this.isLoading = true
+        this.errors = []
+        let url = `plugins/${this.plugin.name}/scan`
+        try {
+          await axios.post(url, this.values)
+        } catch (e) {
+          this.errors = e.backendErrors
+        }
+        this.isLoading = false
+    },
+    async submitAndScan () {
+      await this.submit()
+      await this.scan()
+    }
+  },
+}
+</script>
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index 1326b2c4e114c331e6545d5ce9badadf2b3da3a8..77b456bb32f9246e7fe2decf8bc66e9d5ae1541e 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -257,6 +257,20 @@
           </translate>
         </empty-state>
       </section>
+
+      <section class="ui text container" id="plugins">
+        <div class="ui hidden divider"></div>
+        <h2 class="ui header">
+          <i class="code icon"></i>
+          <div class="content">
+            <translate translate-context="Content/Settings/Title/Noun">Plugins</translate>
+          </div>
+        </h2>
+        <p><translate translate-context="Content/Settings/Paragraph">Use plugins to extend Funkwhale and get additional features.</translate></p>
+        <router-link class="ui basic success button" :to="{name: 'settings.plugins'}">
+          <translate translate-context="Content/Settings/Button.Label">Manage plugins</translate>
+        </router-link>
+      </section>
       <section class="ui text container">
         <div class="ui hidden divider"></div>
         <h2 class="ui header">
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 87d571cda946e3d99a66056d1dff2ae029bd9d55..b6c9170d00a97aeceb8613cf785ee0c45eebe98d 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -154,6 +154,14 @@ export default new Router({
           /* webpackChunkName: "settings" */ "@/components/auth/ApplicationNew"
         )
     },
+    {
+      path: "/settings/plugins",
+      name: "settings.plugins",
+      component: () =>
+        import(
+          /* webpackChunkName: "settings" */ "@/views/auth/Plugins"
+        )
+    },
     {
       path: "/settings/applications/:id/edit",
       name: "settings.applications.edit",
diff --git a/front/src/views/auth/Plugins.vue b/front/src/views/auth/Plugins.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a03dc978a770e5e774e37c6ddeb756e800747e42
--- /dev/null
+++ b/front/src/views/auth/Plugins.vue
@@ -0,0 +1,59 @@
+<template>
+  <main class="main pusher" v-title="labels.title">
+    <section class="ui vertical stripe segment">
+      <div class="ui small text container">
+        <h2>{{ labels.title }}</h2>
+        <div v-if="isLoading" class="ui inverted active dimmer">
+          <div class="ui loader"></div>
+        </div>
+
+        <plugin-form
+          v-if="plugins && plugins.length > 0"
+          v-for="plugin in plugins"
+          :plugin="plugin"
+          :libraries="libraries"
+          :key="plugin.name"></plugin-form>
+        <empty-state v-else></empty-state>
+      </div>
+    </section>
+  </main>
+</template>
+
+<script>
+import axios from 'axios'
+import PluginForm from '@/components/auth/Plugin'
+
+export default {
+  components: {
+    PluginForm
+  },
+  data () {
+    return {
+      isLoading: true,
+      plugins: null,
+      libraries: null,
+    }
+  },
+  async created () {
+    await this.fetchData()
+  },
+  computed: {
+    labels() {
+      let title = this.$pgettext('Head/Login/Title', "Manage plugins")
+      return {
+        title
+      }
+    }
+  },
+  methods: {
+    async fetchData () {
+      this.isLoading = true
+      let response = await axios.get('plugins')
+      this.plugins = response.data
+      response = await axios.get('libraries', {paramis: {scope: 'me', page_size: 50}})
+      this.libraries = response.data.results
+      this.isLoading = false
+    }
+  }
+}
+</script>