diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index ab01e623c3e5bd298ee17fe79d5fdecd604a1aee..93138e9a5df1df3c8239c5d3740f1b2a5504a6ed 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -5,6 +5,7 @@ from rest_framework.urlpatterns import format_suffix_patterns
 from rest_framework_jwt import views as jwt_views
 
 from funkwhale_api.activity import views as activity_views
+from funkwhale_api.common import views as common_views
 from funkwhale_api.music import views
 from funkwhale_api.playlists import views as playlists_views
 from funkwhale_api.subsonic.views import SubsonicViewSet
@@ -24,6 +25,7 @@ router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
 router.register(
     r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
 )
+router.register(r"mutations", common_views.MutationViewSet, "mutations")
 v1_patterns = router.urls
 
 subsonic_router = routers.SimpleRouter(trailing_slash=False)
diff --git a/api/config/asgi.py b/api/config/asgi.py
index 886178cc28ab9640bfdfa6efca1289402c2cbcf7..b4a8105de7117efe9820da558bc10f019e8eb960 100644
--- a/api/config/asgi.py
+++ b/api/config/asgi.py
@@ -1,9 +1,9 @@
 import os
 
-import django
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
+
+import django  # noqa
 
 django.setup()
 
 from .routing import application  # noqa
-
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 91691f2a5bf93a6cb1f9947165df1a7cf9dbdc13..5f69c36d55016c49efa0adffba1bd51afdeff2f1 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -29,7 +29,6 @@ env_file = env("ENV_FILE", default=None)
 if env_file:
     # we have an explicitely specified env file
     # so we try to load and it fail loudly if it does not exist
-    print("ENV_FILE", env_file)
     env.read_env(env_file)
 else:
     # we try to load from .env and config/.env
@@ -150,7 +149,7 @@ if RAVEN_ENABLED:
 
 # Apps specific for this project go here.
 LOCAL_APPS = (
-    "funkwhale_api.common",
+    "funkwhale_api.common.apps.CommonConfig",
     "funkwhale_api.activity.apps.ActivityConfig",
     "funkwhale_api.users",  # custom users app
     # Your stuff: custom apps go here
diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index d6a8ce484caf782cdd8e9ed2ea66efbdb816fc31..632eb320156901f8e24be123796d4b899a27ba8f 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -62,19 +62,6 @@ CELERY_TASK_ALWAYS_EAGER = False
 
 # Your local stuff: Below this line define 3rd party library settings
 
-LOGGING = {
-    "version": 1,
-    "handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}},
-    "loggers": {
-        "django.request": {
-            "handlers": ["console"],
-            "propagate": True,
-            "level": "DEBUG",
-        },
-        "django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"},
-        "": {"level": "DEBUG", "handlers": ["console"]},
-    },
-}
 CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]
 
 
diff --git a/api/funkwhale_api/common/admin.py b/api/funkwhale_api/common/admin.py
index 4124a69b895fbdc5fe51e44d53b99810ac113a7d..3ec6f1f449cf1382e3c2677e0c2e9f8f1cba5319 100644
--- a/api/funkwhale_api/common/admin.py
+++ b/api/funkwhale_api/common/admin.py
@@ -1,6 +1,9 @@
 from django.contrib.admin import register as initial_register, site, ModelAdmin  # noqa
 from django.db.models.fields.related import RelatedField
 
+from . import models
+from . import tasks
+
 
 def register(model):
     """
@@ -17,3 +20,28 @@ def register(model):
         return initial_register(model)(modeladmin)
 
     return decorator
+
+
+def apply(modeladmin, request, queryset):
+    queryset.update(is_approved=True)
+    for id in queryset.values_list("id", flat=True):
+        tasks.apply_mutation.delay(mutation_id=id)
+
+
+apply.short_description = "Approve and apply"
+
+
+@register(models.Mutation)
+class MutationAdmin(ModelAdmin):
+    list_display = [
+        "uuid",
+        "type",
+        "created_by",
+        "creation_date",
+        "applied_date",
+        "is_approved",
+        "is_applied",
+    ]
+    search_fields = ["created_by__preferred_username"]
+    list_filter = ["type", "is_approved", "is_applied"]
+    actions = [apply]
diff --git a/api/funkwhale_api/common/apps.py b/api/funkwhale_api/common/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd671be291395b438ebd15a9caa42f53a81a51c6
--- /dev/null
+++ b/api/funkwhale_api/common/apps.py
@@ -0,0 +1,13 @@
+from django.apps import AppConfig, apps
+
+from . import mutations
+
+
+class CommonConfig(AppConfig):
+    name = "funkwhale_api.common"
+
+    def ready(self):
+        super().ready()
+
+        app_names = [app.name for app in apps.app_configs.values()]
+        mutations.registry.autodiscover(app_names)
diff --git a/api/funkwhale_api/common/decorators.py b/api/funkwhale_api/common/decorators.py
index 71992eff3f9eacfe546df84d77a8faf4924138c2..b93f149f0b0e6f001107d835264545659f42a715 100644
--- a/api/funkwhale_api/common/decorators.py
+++ b/api/funkwhale_api/common/decorators.py
@@ -1,5 +1,17 @@
-from rest_framework import response
+from django.db import transaction
+
 from rest_framework import decorators
+from rest_framework import exceptions
+from rest_framework import response
+from rest_framework import status
+
+from . import filters
+from . import models
+from . import mutations as common_mutations
+from . import serializers
+from . import signals
+from . import tasks
+from . import utils
 
 
 def action_route(serializer_class):
@@ -12,3 +24,67 @@ def action_route(serializer_class):
         return response.Response(result, status=200)
 
     return action
+
+
+def mutations_route(types):
+    """
+    Given a queryset and a list of mutation types, return a view
+    that can be included in any viewset, and serve:
+
+    GET /{id}/mutations/ - list of mutations for the given object
+    POST /{id}/mutations/ - create a mutation for the given object
+    """
+
+    @transaction.atomic
+    def mutations(self, request, *args, **kwargs):
+        obj = self.get_object()
+        if request.method == "GET":
+            queryset = models.Mutation.objects.get_for_target(obj).filter(
+                type__in=types
+            )
+            queryset = queryset.order_by("-creation_date")
+            filterset = filters.MutationFilter(request.GET, queryset=queryset)
+            page = self.paginate_queryset(filterset.qs)
+            if page is not None:
+                serializer = serializers.APIMutationSerializer(page, many=True)
+                return self.get_paginated_response(serializer.data)
+
+            serializer = serializers.APIMutationSerializer(queryset, many=True)
+            return response.Response(serializer.data)
+        if request.method == "POST":
+            if not request.user.is_authenticated:
+                raise exceptions.NotAuthenticated()
+            serializer = serializers.APIMutationSerializer(
+                data=request.data, context={"registry": common_mutations.registry}
+            )
+            serializer.is_valid(raise_exception=True)
+            if not common_mutations.registry.has_perm(
+                actor=request.user.actor,
+                type=serializer.validated_data["type"],
+                obj=obj,
+                perm="approve"
+                if serializer.validated_data.get("is_approved", False)
+                else "suggest",
+            ):
+                raise exceptions.PermissionDenied()
+
+            final_payload = common_mutations.registry.get_validated_payload(
+                type=serializer.validated_data["type"],
+                payload=serializer.validated_data["payload"],
+                obj=obj,
+            )
+            mutation = serializer.save(
+                created_by=request.user.actor,
+                target=obj,
+                payload=final_payload,
+                is_approved=serializer.validated_data.get("is_approved", None),
+            )
+            if mutation.is_approved:
+                utils.on_commit(tasks.apply_mutation.delay, mutation_id=mutation.pk)
+
+            utils.on_commit(
+                signals.mutation_created.send, sender=None, mutation=mutation
+            )
+            return response.Response(serializer.data, status=status.HTTP_201_CREATED)
+
+    return decorators.action(methods=["get", "post"], detail=True)(mutations)
diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..6919f9c3771ec81c9e4019cfa9e42d4d30e99494
--- /dev/null
+++ b/api/funkwhale_api/common/factories.py
@@ -0,0 +1,25 @@
+import factory
+
+from funkwhale_api.factories import registry, NoUpdateOnCreate
+
+from funkwhale_api.federation import factories as federation_factories
+
+
+@registry.register
+class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
+    fid = factory.Faker("federation_url")
+    uuid = factory.Faker("uuid4")
+    created_by = factory.SubFactory(federation_factories.ActorFactory)
+    summary = factory.Faker("paragraph")
+    type = "update"
+
+    class Meta:
+        model = "common.Mutation"
+
+    @factory.post_generation
+    def target(self, create, extracted, **kwargs):
+        if not create:
+            # Simple build, do nothing.
+            return
+        self.target = extracted
+        self.save()
diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
index a0f10efe3a22cb2c903336a073b905f06994e69c..47e673cb5b567cfebfc8ad14ef6aae07acf44687 100644
--- a/api/funkwhale_api/common/fields.py
+++ b/api/funkwhale_api/common/fields.py
@@ -1,4 +1,5 @@
 import django_filters
+from django import forms
 from django.db import models
 
 from . import search
@@ -46,5 +47,8 @@ class SmartSearchFilter(django_filters.CharFilter):
     def filter(self, qs, value):
         if not value:
             return qs
-        cleaned = self.config.clean(value)
+        try:
+            cleaned = self.config.clean(value)
+        except forms.ValidationError:
+            return qs.none()
         return search.apply(qs, cleaned)
diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..4825d3b5d8d4a934d458faead03baa693f178b94
--- /dev/null
+++ b/api/funkwhale_api/common/filters.py
@@ -0,0 +1,126 @@
+from django import forms
+from django.db.models import Q
+
+from django_filters import widgets
+from django_filters import rest_framework as filters
+
+from . import fields
+from . import models
+from . import search
+
+
+class NoneObject(object):
+    def __eq__(self, other):
+        return other.__class__ == NoneObject
+
+
+NONE = NoneObject()
+NULL_BOOLEAN_CHOICES = [
+    (True, True),
+    ("true", True),
+    ("True", True),
+    ("1", True),
+    ("yes", True),
+    (False, False),
+    ("false", False),
+    ("False", False),
+    ("0", False),
+    ("no", False),
+    ("None", NONE),
+    ("none", NONE),
+    ("Null", NONE),
+    ("null", NONE),
+]
+
+
+class CoerceChoiceField(forms.ChoiceField):
+    """
+    Same as forms.ChoiceField but will return the second value
+    in the choices tuple instead of the user provided one
+    """
+
+    def clean(self, value):
+        if value is None:
+            return value
+        v = super().clean(value)
+        try:
+            return [b for a, b in self.choices if v == a][0]
+        except IndexError:
+            raise forms.ValidationError("Invalid value {}".format(value))
+
+
+class NullBooleanFilter(filters.ChoiceFilter):
+    field_class = CoerceChoiceField
+
+    def __init__(self, *args, **kwargs):
+        self.choices = NULL_BOOLEAN_CHOICES
+        kwargs["choices"] = self.choices
+        super().__init__(*args, **kwargs)
+
+    def filter(self, qs, value):
+        if value in ["", None]:
+            return qs
+        if value == NONE:
+            value = None
+        qs = self.get_method(qs)(
+            **{"%s__%s" % (self.field_name, self.lookup_expr): value}
+        )
+        return qs.distinct() if self.distinct else qs
+
+
+def clean_null_boolean_filter(v):
+    v = CoerceChoiceField(choices=NULL_BOOLEAN_CHOICES).clean(v)
+    if v == NONE:
+        v = None
+
+    return v
+
+
+def get_null_boolean_filter(name):
+    return {"handler": lambda v: Q(**{name: clean_null_boolean_filter(v)})}
+
+
+class DummyTypedMultipleChoiceField(forms.TypedMultipleChoiceField):
+    def valid_value(self, value):
+        return True
+
+
+class QueryArrayWidget(widgets.QueryArrayWidget):
+    """
+    Until https://github.com/carltongibson/django-filter/issues/1047 is fixed
+    """
+
+    def value_from_datadict(self, data, files, name):
+        data = data.copy()
+        return super().value_from_datadict(data, files, name)
+
+
+class MultipleQueryFilter(filters.TypedMultipleChoiceFilter):
+    field_class = DummyTypedMultipleChoiceField
+
+    def __init__(self, *args, **kwargs):
+        kwargs["widget"] = QueryArrayWidget()
+        super().__init__(*args, **kwargs)
+        self.lookup_expr = "in"
+
+
+class MutationFilter(filters.FilterSet):
+    is_approved = NullBooleanFilter("is_approved")
+    q = fields.SmartSearchFilter(
+        config=search.SearchConfig(
+            search_fields={
+                "summary": {"to": "summary"},
+                "fid": {"to": "fid"},
+                "type": {"to": "type"},
+            },
+            filter_fields={
+                "domain": {"to": "created_by__domain__name__iexact"},
+                "is_approved": get_null_boolean_filter("is_approved"),
+                "is_applied": {"to": "is_applied"},
+            },
+        )
+    )
+
+    class Meta:
+        model = models.Mutation
+        fields = ["is_approved", "is_applied", "type"]
diff --git a/api/funkwhale_api/common/migrations/0002_mutation.py b/api/funkwhale_api/common/migrations/0002_mutation.py
new file mode 100644
index 0000000000000000000000000000000000000000..f1f756fd3a173e462954e6760e5b73f976532061
--- /dev/null
+++ b/api/funkwhale_api/common/migrations/0002_mutation.py
@@ -0,0 +1,91 @@
+# Generated by Django 2.1.5 on 2019-01-31 15:44
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ("federation", "0017_auto_20190130_0926"),
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("common", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Mutation",
+            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)),
+                (
+                    "uuid",
+                    models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
+                ),
+                ("type", models.CharField(db_index=True, max_length=100)),
+                ("is_approved", models.NullBooleanField(default=None)),
+                ("is_applied", models.NullBooleanField(default=None)),
+                (
+                    "creation_date",
+                    models.DateTimeField(
+                        db_index=True, default=django.utils.timezone.now
+                    ),
+                ),
+                (
+                    "applied_date",
+                    models.DateTimeField(blank=True, db_index=True, null=True),
+                ),
+                ("summary", models.TextField(max_length=2000, blank=True, null=True)),
+                ("payload", django.contrib.postgres.fields.jsonb.JSONField()),
+                (
+                    "previous_state",
+                    django.contrib.postgres.fields.jsonb.JSONField(
+                        null=True, default=None
+                    ),
+                ),
+                ("target_id", models.IntegerField(null=True)),
+                (
+                    "approved_by",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="approved_mutations",
+                        to="federation.Actor",
+                    ),
+                ),
+                (
+                    "created_by",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="created_mutations",
+                        to="federation.Actor",
+                    ),
+                ),
+                (
+                    "target_content_type",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="targeting_mutations",
+                        to="contenttypes.ContentType",
+                    ),
+                ),
+            ],
+        )
+    ]
diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b9cc1e57c9b8063c3719ab9431d75a8c1a131d5
--- /dev/null
+++ b/api/funkwhale_api/common/models.py
@@ -0,0 +1,89 @@
+import uuid
+
+from django.contrib.postgres.fields import JSONField
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.db import models, transaction
+from django.utils import timezone
+from django.urls import reverse
+
+from funkwhale_api.federation import utils as federation_utils
+
+
+class MutationQuerySet(models.QuerySet):
+    def get_for_target(self, target):
+        content_type = ContentType.objects.get_for_model(target)
+        return self.filter(target_content_type=content_type, target_id=target.pk)
+
+
+class Mutation(models.Model):
+    fid = models.URLField(unique=True, max_length=500, db_index=True)
+    uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
+    created_by = models.ForeignKey(
+        "federation.Actor",
+        related_name="created_mutations",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+    )
+    approved_by = models.ForeignKey(
+        "federation.Actor",
+        related_name="approved_mutations",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+    )
+
+    type = models.CharField(max_length=100, db_index=True)
+    # None = no choice, True = approved, False = refused
+    is_approved = models.NullBooleanField(default=None)
+
+    # None = not applied, True = applied, False = failed
+    is_applied = models.NullBooleanField(default=None)
+    creation_date = models.DateTimeField(default=timezone.now, db_index=True)
+    applied_date = models.DateTimeField(null=True, blank=True, db_index=True)
+    summary = models.TextField(max_length=2000, null=True, blank=True)
+
+    payload = JSONField()
+    previous_state = JSONField(null=True, default=None)
+
+    target_id = models.IntegerField(null=True)
+    target_content_type = models.ForeignKey(
+        ContentType,
+        null=True,
+        on_delete=models.CASCADE,
+        related_name="targeting_mutations",
+    )
+    target = GenericForeignKey("target_content_type", "target_id")
+
+    objects = MutationQuerySet.as_manager()
+
+    def get_federation_id(self):
+        if self.fid:
+            return self.fid
+
+        return federation_utils.full_url(
+            reverse("federation:edits-detail", kwargs={"uuid": self.uuid})
+        )
+
+    def save(self, **kwargs):
+        if not self.pk and not self.fid:
+            self.fid = self.get_federation_id()
+
+        return super().save(**kwargs)
+
+    @transaction.atomic
+    def apply(self):
+        from . import mutations
+
+        if self.is_applied:
+            raise ValueError("Mutation was already applied")
+
+        previous_state = mutations.registry.apply(
+            type=self.type, obj=self.target, payload=self.payload
+        )
+        self.previous_state = previous_state
+        self.is_applied = True
+        self.applied_date = timezone.now()
+        self.save(update_fields=["is_applied", "applied_date", "previous_state"])
+        return previous_state
diff --git a/api/funkwhale_api/common/mutations.py b/api/funkwhale_api/common/mutations.py
new file mode 100644
index 0000000000000000000000000000000000000000..11624e9f629312ce66b35e021a41efddbb683e2f
--- /dev/null
+++ b/api/funkwhale_api/common/mutations.py
@@ -0,0 +1,150 @@
+import persisting_theory
+
+from rest_framework import serializers
+
+from django.db import models
+
+
+class ConfNotFound(KeyError):
+    pass
+
+
+class Registry(persisting_theory.Registry):
+    look_into = "mutations"
+
+    def connect(self, type, klass, perm_checkers=None):
+        def decorator(serializer_class):
+            t = self.setdefault(type, {})
+            t[klass] = {
+                "serializer_class": serializer_class,
+                "perm_checkers": perm_checkers or {},
+            }
+            return serializer_class
+
+        return decorator
+
+    def apply(self, type, obj, payload):
+        conf = self.get_conf(type, obj)
+        serializer = conf["serializer_class"](obj, data=payload)
+        serializer.is_valid(raise_exception=True)
+        previous_state = serializer.get_previous_state(obj, serializer.validated_data)
+        serializer.apply(obj, serializer.validated_data)
+        return previous_state
+
+    def is_valid(self, type, obj, payload):
+        conf = self.get_conf(type, obj)
+        serializer = conf["serializer_class"](obj, data=payload)
+        return serializer.is_valid(raise_exception=True)
+
+    def get_validated_payload(self, type, obj, payload):
+        conf = self.get_conf(type, obj)
+        serializer = conf["serializer_class"](obj, data=payload)
+        serializer.is_valid(raise_exception=True)
+        return serializer.payload_serialize(serializer.validated_data)
+
+    def has_perm(self, perm, type, obj, actor):
+        if perm not in ["approve", "suggest"]:
+            raise ValueError("Invalid permission {}".format(perm))
+        conf = self.get_conf(type, obj)
+        checker = conf["perm_checkers"].get(perm)
+        if not checker:
+            return False
+        return checker(obj=obj, actor=actor)
+
+    def get_conf(self, type, obj):
+        try:
+            type_conf = self[type]
+        except KeyError:
+            raise ConfNotFound("{} is not a registered mutation".format(type))
+
+        try:
+            conf = type_conf[obj.__class__]
+        except KeyError:
+            try:
+                conf = type_conf[None]
+            except KeyError:
+                raise ConfNotFound(
+                    "No mutation configuration found for {}".format(obj.__class__)
+                )
+        return conf
+
+
+class MutationSerializer(serializers.Serializer):
+    def apply(self, obj, validated_data):
+        raise NotImplementedError()
+
+    def get_previous_state(self, obj, validated_data):
+        return
+
+    def payload_serialize(self, data):
+        return data
+
+
+class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
+    serialized_relations = {}
+
+    def __init__(self, *args, **kwargs):
+        # we force partial mode, because update mutations are partial
+        kwargs.setdefault("partial", True)
+        super().__init__(*args, **kwargs)
+
+    def apply(self, obj, validated_data):
+        return self.update(obj, validated_data)
+
+    def validate(self, validated_data):
+        if not validated_data:
+            raise serializers.ValidationError("You must update at least one field")
+
+        return super().validate(validated_data)
+
+    def db_serialize(self, validated_data):
+        data = {}
+        # ensure model fields are serialized properly
+        for key, value in list(validated_data.items()):
+            if not isinstance(value, models.Model):
+                data[key] = value
+                continue
+            field = self.serialized_relations[key]
+            data[key] = getattr(value, field)
+        return data
+
+    def payload_serialize(self, data):
+        data = super().payload_serialize(data)
+        # we use our serialized_relations configuration
+        # to ensure we store ids instead of model instances in our json
+        # payload
+        for field, attr in self.serialized_relations.items():
+            data[field] = getattr(data[field], attr)
+        return data
+
+    def create(self, validated_data):
+        validated_data = self.db_serialize(validated_data)
+        return super().create(validated_data)
+
+    def get_previous_state(self, obj, validated_data):
+        return get_update_previous_state(
+            obj,
+            *list(validated_data.keys()),
+            serialized_relations=self.serialized_relations
+        )
+
+
+def get_update_previous_state(obj, *fields, serialized_relations={}):
+    if not fields:
+        raise ValueError("You need to provide at least one field")
+
+    state = {}
+    for field in fields:
+        value = getattr(obj, field)
+        if isinstance(value, models.Model):
+            # we store the related object id and repr for better UX
+            id_field = serialized_relations[field]
+            related_value = getattr(value, id_field)
+            state[field] = {"value": related_value, "repr": str(value)}
+        else:
+            state[field] = {"value": value}
+
+    return state
+
+
+registry = Registry()
diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py
index 70aecd632f6e77109dc3e8d51e06695acd19d6cf..622cb29dd174d1c7dbc3ece052b6e8ab5a5068a4 100644
--- a/api/funkwhale_api/common/search.py
+++ b/api/funkwhale_api/common/search.py
@@ -103,9 +103,7 @@ class SearchConfig:
             return
 
         matching = [t for t in tokens if t["key"] in self.filter_fields]
-        queries = [
-            Q(**{self.filter_fields[t["key"]]["to"]: t["value"]}) for t in matching
-        ]
+        queries = [self.get_filter_query(token) for token in matching]
         query = None
         for q in queries:
             if not query:
@@ -114,6 +112,26 @@ class SearchConfig:
                 query = query & q
         return query
 
+    def get_filter_query(self, token):
+        raw_value = token["value"]
+        try:
+            field = self.filter_fields[token["key"]]["field"]
+            value = field.clean(raw_value)
+        except KeyError:
+            # no cleaning to apply
+            value = raw_value
+        try:
+            query_field = self.filter_fields[token["key"]]["to"]
+            return Q(**{query_field: value})
+        except KeyError:
+            pass
+
+        # we don't have a basic filter -> field mapping, this likely means we
+        # have a dynamic handler in the config
+        handler = self.filter_fields[token["key"]]["handler"]
+        value = handler(value)
+        return value
+
     def clean_types(self, tokens):
         if not self.types:
             return []
diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py
index fafa6152d09edf95052f2346b16e3135756a6114..59b513f37aa057d843df6a4a5405381be71c2c8f 100644
--- a/api/funkwhale_api/common/serializers.py
+++ b/api/funkwhale_api/common/serializers.py
@@ -10,6 +10,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile
 from django.utils.encoding import smart_text
 from django.utils.translation import ugettext_lazy as _
 
+from . import models
+
 
 class RelatedField(serializers.RelatedField):
     default_error_messages = {
@@ -216,3 +218,57 @@ class StripExifImageField(serializers.ImageField):
         return SimpleUploadedFile(
             file_obj.name, content, content_type=file_obj.content_type
         )
+
+
+from funkwhale_api.federation import serializers as federation_serializers  # noqa
+
+TARGET_ID_TYPE_MAPPING = {
+    "music.Track": ("id", "track"),
+    "music.Artist": ("id", "artist"),
+    "music.Album": ("id", "album"),
+}
+
+
+class APIMutationSerializer(serializers.ModelSerializer):
+    created_by = federation_serializers.APIActorSerializer(read_only=True)
+    target = serializers.SerializerMethodField()
+
+    class Meta:
+        model = models.Mutation
+        fields = [
+            "fid",
+            "uuid",
+            "type",
+            "creation_date",
+            "applied_date",
+            "is_approved",
+            "is_applied",
+            "created_by",
+            "approved_by",
+            "summary",
+            "payload",
+            "previous_state",
+            "target",
+        ]
+        read_only_fields = [
+            "uuid",
+            "creation_date",
+            "fid",
+            "is_applied",
+            "created_by",
+            "approved_by",
+            "previous_state",
+        ]
+
+    def get_target(self, obj):
+        target = obj.target
+        if not target:
+            return
+
+        id_field, type = TARGET_ID_TYPE_MAPPING[target._meta.label]
+        return {"type": type, "id": getattr(target, id_field), "repr": str(target)}
+
+    def validate_type(self, value):
+        if value not in self.context["registry"]:
+            raise serializers.ValidationError("Invalid mutation type {}".format(value))
+        return value
diff --git a/api/funkwhale_api/common/signals.py b/api/funkwhale_api/common/signals.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d8e953ccf7fed7033579ea68ce5aeb1f047bed9
--- /dev/null
+++ b/api/funkwhale_api/common/signals.py
@@ -0,0 +1,6 @@
+import django.dispatch
+
+mutation_created = django.dispatch.Signal(providing_args=["mutation"])
+mutation_updated = django.dispatch.Signal(
+    providing_args=["mutation", "old_is_approved", "new_is_approved"]
+)
diff --git a/api/funkwhale_api/common/tasks.py b/api/funkwhale_api/common/tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..994b0bdfff13a27a5eec0e99a87c72c11b39287d
--- /dev/null
+++ b/api/funkwhale_api/common/tasks.py
@@ -0,0 +1,59 @@
+from django.db import transaction
+from django.dispatch import receiver
+
+
+from funkwhale_api.common import channels
+from funkwhale_api.taskapp import celery
+
+from . import models
+from . import serializers
+from . import signals
+
+
+@celery.app.task(name="common.apply_mutation")
+@transaction.atomic
+@celery.require_instance(
+    models.Mutation.objects.exclude(is_applied=True).select_for_update(), "mutation"
+)
+def apply_mutation(mutation):
+    mutation.apply()
+
+
+@receiver(signals.mutation_created)
+def broadcast_mutation_created(mutation, **kwargs):
+    group = "instance_activity"
+    channels.group_send(
+        group,
+        {
+            "type": "event.send",
+            "text": "",
+            "data": {
+                "type": "mutation.created",
+                "mutation": serializers.APIMutationSerializer(mutation).data,
+                "pending_review_count": models.Mutation.objects.filter(
+                    is_approved=None
+                ).count(),
+            },
+        },
+    )
+
+
+@receiver(signals.mutation_updated)
+def broadcast_mutation_update(mutation, old_is_approved, new_is_approved, **kwargs):
+    group = "instance_activity"
+    channels.group_send(
+        group,
+        {
+            "type": "event.send",
+            "text": "",
+            "data": {
+                "type": "mutation.updated",
+                "mutation": serializers.APIMutationSerializer(mutation).data,
+                "pending_review_count": models.Mutation.objects.filter(
+                    is_approved=None
+                ).count(),
+                "old_is_approved": old_is_approved,
+                "new_is_approved": new_is_approved,
+            },
+        },
+    )
diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py
index fe7d6733aba97fa481adc85b5ce4c19a1beee9ce..743c95095b1f1042b3e411fe6cb784a18e5d1dd6 100644
--- a/api/funkwhale_api/common/views.py
+++ b/api/funkwhale_api/common/views.py
@@ -1,3 +1,21 @@
+from django.db import transaction
+
+from rest_framework.decorators import action
+from rest_framework import exceptions
+from rest_framework import mixins
+from rest_framework import permissions
+from rest_framework import response
+from rest_framework import viewsets
+
+from . import filters
+from . import models
+from . import mutations
+from . import serializers
+from . import signals
+from . import tasks
+from . import utils
+
+
 class SkipFilterForGetObject:
     def get_object(self, *args, **kwargs):
         setattr(self.request, "_skip_filters", True)
@@ -7,3 +25,98 @@ class SkipFilterForGetObject:
         if getattr(self.request, "_skip_filters", False):
             return queryset
         return super().filter_queryset(queryset)
+
+
+class MutationViewSet(
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+    viewsets.GenericViewSet,
+):
+    lookup_field = "uuid"
+    queryset = (
+        models.Mutation.objects.all()
+        .order_by("-creation_date")
+        .select_related("created_by", "approved_by")
+        .prefetch_related("target")
+    )
+    serializer_class = serializers.APIMutationSerializer
+    permission_classes = [permissions.IsAuthenticated]
+    ordering_fields = ("creation_date",)
+    filterset_class = filters.MutationFilter
+
+    def perform_destroy(self, instance):
+        if instance.is_applied:
+            raise exceptions.PermissionDenied("You cannot delete an applied mutation")
+
+        actor = self.request.user.actor
+        is_owner = actor == instance.created_by
+
+        if not any(
+            [
+                is_owner,
+                mutations.registry.has_perm(
+                    perm="approve", type=instance.type, obj=instance.target, actor=actor
+                ),
+            ]
+        ):
+            raise exceptions.PermissionDenied()
+
+        return super().perform_destroy(instance)
+
+    @action(detail=True, methods=["post"])
+    @transaction.atomic
+    def approve(self, request, *args, **kwargs):
+        instance = self.get_object()
+        if instance.is_applied:
+            return response.Response(
+                {"error": "This mutation was already applied"}, status=403
+            )
+        actor = self.request.user.actor
+        can_approve = mutations.registry.has_perm(
+            perm="approve", type=instance.type, obj=instance.target, actor=actor
+        )
+
+        if not can_approve:
+            raise exceptions.PermissionDenied()
+        previous_is_approved = instance.is_approved
+        instance.approved_by = actor
+        instance.is_approved = True
+        instance.save(update_fields=["approved_by", "is_approved"])
+        utils.on_commit(tasks.apply_mutation.delay, mutation_id=instance.id)
+        utils.on_commit(
+            signals.mutation_updated.send,
+            sender=None,
+            mutation=instance,
+            old_is_approved=previous_is_approved,
+            new_is_approved=instance.is_approved,
+        )
+        return response.Response({}, status=200)
+
+    @action(detail=True, methods=["post"])
+    @transaction.atomic
+    def reject(self, request, *args, **kwargs):
+        instance = self.get_object()
+        if instance.is_applied:
+            return response.Response(
+                {"error": "This mutation was already applied"}, status=403
+            )
+        actor = self.request.user.actor
+        can_approve = mutations.registry.has_perm(
+            perm="approve", type=instance.type, obj=instance.target, actor=actor
+        )
+
+        if not can_approve:
+            raise exceptions.PermissionDenied()
+        previous_is_approved = instance.is_approved
+        instance.approved_by = actor
+        instance.is_approved = False
+        instance.save(update_fields=["approved_by", "is_approved"])
+        utils.on_commit(
+            signals.mutation_updated.send,
+            sender=None,
+            mutation=instance,
+            old_is_approved=previous_is_approved,
+            new_is_approved=instance.is_approved,
+        )
+        return response.Response({}, status=200)
diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py
index f8347d1ebf47c2f300d6b7d0941809d7457d7ed2..f7d5006da0cdf50df601da96e78fc5b8e97d00ca 100644
--- a/api/funkwhale_api/federation/urls.py
+++ b/api/funkwhale_api/federation/urls.py
@@ -8,6 +8,7 @@ music_router = routers.SimpleRouter(trailing_slash=False)
 
 router.register(r"federation/shared", views.SharedViewSet, "shared")
 router.register(r"federation/actors", views.ActorViewSet, "actors")
+router.register(r"federation/edits", views.EditViewSet, "edits")
 router.register(r".well-known", views.WellKnownViewSet, "well-known")
 
 music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 3b322e915144bec01e536640022402a1b20429bf..13791ec213665834a854d42ac953eeb72e35d934 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -69,6 +69,15 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
         return response.Response({})
 
 
+class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
+    lookup_field = "uuid"
+    authentication_classes = [authentication.SignatureAuthentication]
+    permission_classes = []
+    renderer_classes = [renderers.ActivityPubRenderer]
+    # queryset = common_models.Mutation.objects.local().select_related()
+    # serializer_class = serializers.ActorSerializer
+
+
 class WellKnownViewSet(viewsets.GenericViewSet):
     authentication_classes = []
     permission_classes = []
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index 3134ae19c987f086d3b4d80ce1ceb4abb903e976..fa5a10f6d4397ed296d8710d6b76d6dc6646ec28 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -1,6 +1,7 @@
 from django_filters import rest_framework as filters
 
 from funkwhale_api.common import fields
+from funkwhale_api.common import filters as common_filters
 from funkwhale_api.common import search
 from funkwhale_api.moderation import filters as moderation_filters
 
@@ -28,12 +29,14 @@ class ArtistFilter(moderation_filters.HiddenContentFilterSet):
 class TrackFilter(moderation_filters.HiddenContentFilterSet):
     q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
     playable = filters.BooleanFilter(field_name="_", method="filter_playable")
+    id = common_filters.MultipleQueryFilter(coerce=int)
 
     class Meta:
         model = models.Track
         fields = {
             "title": ["exact", "iexact", "startswith", "icontains"],
             "playable": ["exact"],
+            "id": ["exact"],
             "artist": ["exact"],
             "album": ["exact"],
             "license": ["exact"],
diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py
new file mode 100644
index 0000000000000000000000000000000000000000..51efa0ab8cd70f7c241c460937a76b75ee8ab658
--- /dev/null
+++ b/api/funkwhale_api/music/mutations.py
@@ -0,0 +1,24 @@
+from funkwhale_api.common import mutations
+
+from . import models
+
+
+def can_suggest(obj, actor):
+    return True
+
+
+def can_approve(obj, actor):
+    return actor.user and actor.user.get_permissions()["library"]
+
+
+@mutations.registry.connect(
+    "update",
+    models.Track,
+    perm_checkers={"suggest": can_suggest, "approve": can_approve},
+)
+class TrackMutationSerializer(mutations.UpdateMutationSerializer):
+    serialized_relations = {"license": "code"}
+
+    class Meta:
+        model = models.Track
+        fields = ["license", "title", "position"]
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index d07fd27ec40f9a7173e563c9c50f132238adb13d..f6bed500c154b5320522207f18cde25d9d917690 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -15,6 +15,7 @@ from rest_framework.decorators import action
 from rest_framework.response import Response
 from taggit.models import Tag
 
+from funkwhale_api.common import decorators as common_decorators
 from funkwhale_api.common import permissions as common_permissions
 from funkwhale_api.common import preferences
 from funkwhale_api.common import utils as common_utils
@@ -186,6 +187,8 @@ class TrackViewSet(
         "artist__name",
     )
 
+    mutations = common_decorators.mutations_route(types=["update"])
+
     def get_queryset(self):
         queryset = super().get_queryset()
         filter_favorites = self.request.GET.get("favorites", None)
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index c75604f6eb94ea97b26247604b13bf2c01fa6dfe..79ef045618310cf908776180f1ea95c743867481 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -94,6 +94,7 @@ class UserWriteSerializer(serializers.ModelSerializer):
 class UserReadSerializer(serializers.ModelSerializer):
 
     permissions = serializers.SerializerMethodField()
+    full_username = serializers.SerializerMethodField()
     avatar = avatar_field
 
     class Meta:
@@ -101,6 +102,7 @@ class UserReadSerializer(serializers.ModelSerializer):
         fields = [
             "id",
             "username",
+            "full_username",
             "name",
             "email",
             "is_staff",
@@ -114,6 +116,10 @@ class UserReadSerializer(serializers.ModelSerializer):
     def get_permissions(self, o):
         return o.get_permissions()
 
+    def get_full_username(self, o):
+        if o.actor:
+            return o.actor.full_username
+
 
 class MeSerializer(UserReadSerializer):
     quota_status = serializers.SerializerMethodField()
diff --git a/api/tests/common/test_decorators.py b/api/tests/common/test_decorators.py
new file mode 100644
index 0000000000000000000000000000000000000000..66e692585a5bca823c87cf2a8b5de5b5efa4d8ae
--- /dev/null
+++ b/api/tests/common/test_decorators.py
@@ -0,0 +1,122 @@
+import pytest
+
+from rest_framework import viewsets
+
+from funkwhale_api.common import decorators
+from funkwhale_api.common import models
+from funkwhale_api.common import mutations
+from funkwhale_api.common import serializers
+from funkwhale_api.common import signals
+from funkwhale_api.common import tasks
+from funkwhale_api.music import models as music_models
+from funkwhale_api.music import licenses
+
+
+class V(viewsets.ModelViewSet):
+    queryset = music_models.Track.objects.all()
+    mutations = decorators.mutations_route(types=["update"])
+    permission_classes = []
+
+
+def test_mutations_route_list(factories, api_request):
+    track = factories["music.Track"]()
+    mutation = factories["common.Mutation"](target=track, type="update", payload="")
+    factories["common.Mutation"](target=track, type="noop", payload="")
+
+    view = V.as_view({"get": "mutations"})
+    expected = {
+        "next": None,
+        "previous": None,
+        "count": 1,
+        "results": [serializers.APIMutationSerializer(mutation).data],
+    }
+
+    request = api_request.get("/")
+    response = view(request, pk=track.pk)
+
+    assert response.status_code == 200
+    assert response.data == expected
+
+
+@pytest.mark.parametrize("is_approved", [False, True])
+def test_mutations_route_create_success(factories, api_request, is_approved, mocker):
+    licenses.load(licenses.LICENSES)
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+    user = factories["users.User"](permission_library=True)
+    actor = user.create_actor()
+    track = factories["music.Track"](title="foo")
+    view = V.as_view({"post": "mutations"})
+
+    request = api_request.post(
+        "/",
+        {
+            "type": "update",
+            "payload": {"title": "bar", "unknown": "test", "license": "cc-by-nc-4.0"},
+            "summary": "hello",
+            "is_approved": is_approved,
+        },
+        format="json",
+    )
+    setattr(request, "user", user)
+    setattr(request, "session", {})
+    response = view(request, pk=track.pk)
+
+    assert response.status_code == 201
+
+    mutation = models.Mutation.objects.get_for_target(track).latest("id")
+
+    assert mutation.type == "update"
+    assert mutation.payload == {"title": "bar", "license": "cc-by-nc-4.0"}
+    assert mutation.created_by == actor
+    assert mutation.is_approved is is_approved
+    assert mutation.is_applied is None
+    assert mutation.target == track
+    assert mutation.summary == "hello"
+
+    if is_approved:
+        on_commit.assert_any_call(tasks.apply_mutation.delay, mutation_id=mutation.pk)
+    expected = serializers.APIMutationSerializer(mutation).data
+    assert response.data == expected
+    on_commit.assert_any_call(
+        signals.mutation_created.send, mutation=mutation, sender=None
+    )
+
+
+def test_mutations_route_create_no_auth(factories, api_request):
+    track = factories["music.Track"](title="foo")
+    view = V.as_view({"post": "mutations"})
+
+    request = api_request.post("/", {}, format="json")
+    response = view(request, pk=track.pk)
+
+    assert response.status_code == 401
+
+
+@pytest.mark.parametrize("is_approved", [False, True])
+def test_mutations_route_create_no_perm(factories, api_request, mocker, is_approved):
+    track = factories["music.Track"](title="foo")
+    view = V.as_view({"post": "mutations"})
+    user = factories["users.User"]()
+    actor = user.create_actor()
+    has_perm = mocker.patch.object(mutations.registry, "has_perm", return_value=False)
+    request = api_request.post(
+        "/",
+        {
+            "type": "update",
+            "payload": {"title": "bar", "unknown": "test"},
+            "summary": "hello",
+            "is_approved": is_approved,
+        },
+        format="json",
+    )
+    setattr(request, "user", user)
+    setattr(request, "session", {})
+    response = view(request, pk=track.pk)
+
+    assert response.status_code == 403
+    has_perm.assert_called_once_with(
+        actor=actor,
+        obj=track,
+        type="update",
+        perm="approve" if is_approved else "suggest",
+    )
diff --git a/api/tests/common/test_filters.py b/api/tests/common/test_filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e89dfa37c1dc2800569aca928b9452e0b2d29fe
--- /dev/null
+++ b/api/tests/common/test_filters.py
@@ -0,0 +1,38 @@
+import pytest
+
+from funkwhale_api.common import filters
+
+
+@pytest.mark.parametrize(
+    "value, expected",
+    [
+        (True, True),
+        ("True", True),
+        ("true", True),
+        ("1", True),
+        ("yes", True),
+        (False, False),
+        ("False", False),
+        ("false", False),
+        ("0", False),
+        ("no", False),
+        ("None", None),
+        ("none", None),
+        ("Null", None),
+        ("null", None),
+    ],
+)
+def test_mutation_filter_is_approved(value, expected, factories):
+    mutations = {
+        True: factories["common.Mutation"](is_approved=True, payload={}),
+        False: factories["common.Mutation"](is_approved=False, payload={}),
+        None: factories["common.Mutation"](is_approved=None, payload={}),
+    }
+
+    qs = mutations[True].__class__.objects.all()
+
+    filterset = filters.MutationFilter(
+        {"q": "is_approved:{}".format(value)}, queryset=qs
+    )
+
+    assert list(filterset.qs) == [mutations[expected]]
diff --git a/api/tests/common/test_models.py b/api/tests/common/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..25c9befda809de508864b39f7bdc551f6600b14b
--- /dev/null
+++ b/api/tests/common/test_models.py
@@ -0,0 +1,17 @@
+import pytest
+
+from django.urls import reverse
+
+from funkwhale_api.federation import utils as federation_utils
+
+
+@pytest.mark.parametrize(
+    "model,factory_args,namespace",
+    [("common.Mutation", {"created_by__local": True}, "federation:edits-detail")],
+)
+def test_mutation_fid_is_populated(factories, model, factory_args, namespace):
+    instance = factories[model](**factory_args, fid=None, payload={})
+
+    assert instance.fid == federation_utils.full_url(
+        reverse(namespace, kwargs={"uuid": instance.uuid})
+    )
diff --git a/api/tests/common/test_mutations.py b/api/tests/common/test_mutations.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb2a08500b5c45ab73043257f01b35954b400674
--- /dev/null
+++ b/api/tests/common/test_mutations.py
@@ -0,0 +1,141 @@
+import pytest
+
+from funkwhale_api.common import mutations
+
+from rest_framework import serializers
+
+
+@pytest.fixture
+def mutations_registry():
+    return mutations.Registry()
+
+
+def test_apply_mutation(mutations_registry):
+    class Obj:
+        pass
+
+    obj = Obj()
+
+    @mutations_registry.connect("foo", Obj)
+    class S(mutations.MutationSerializer):
+        foo = serializers.ChoiceField(choices=["bar", "baz"])
+
+        def apply(self, obj, validated_data):
+            setattr(obj, "foo", validated_data["foo"])
+
+    with pytest.raises(mutations.ConfNotFound):
+        mutations_registry.apply("foo", object(), payload={"foo": "nope"})
+
+    with pytest.raises(serializers.ValidationError):
+        mutations_registry.apply("foo", obj, payload={"foo": "nope"})
+
+    mutations_registry.apply("foo", obj, payload={"foo": "bar"})
+
+    assert obj.foo == "bar"
+
+
+def test_apply_update_mutation(factories, mutations_registry, mocker):
+    user = factories["users.User"](email="hello@test.email")
+    get_update_previous_state = mocker.patch.object(
+        mutations, "get_update_previous_state"
+    )
+
+    @mutations_registry.connect("update", user.__class__)
+    class S(mutations.UpdateMutationSerializer):
+        class Meta:
+            model = user.__class__
+            fields = ["username", "email"]
+
+    previous_state = mutations_registry.apply(
+        "update", user, payload={"username": "foo"}
+    )
+    assert previous_state == get_update_previous_state.return_value
+    get_update_previous_state.assert_called_once_with(
+        user, "username", serialized_relations={}
+    )
+    user.refresh_from_db()
+
+    assert user.username == "foo"
+    assert user.email == "hello@test.email"
+
+
+def test_db_serialize_update_mutation(factories, mutations_registry, mocker):
+    user = factories["users.User"](email="hello@test.email", with_actor=True)
+
+    class S(mutations.UpdateMutationSerializer):
+        serialized_relations = {"actor": "full_username"}
+
+        class Meta:
+            model = user.__class__
+            fields = ["actor"]
+
+    expected = {"actor": user.actor.full_username}
+    assert S().db_serialize({"actor": user.actor}) == expected
+
+
+def test_is_valid_mutation(factories, mutations_registry):
+    user = factories["users.User"].build()
+
+    @mutations_registry.connect("update", user.__class__)
+    class S(mutations.UpdateMutationSerializer):
+        class Meta:
+            model = user.__class__
+            fields = ["email"]
+
+    with pytest.raises(serializers.ValidationError):
+        mutations_registry.is_valid("update", user, payload={"email": "foo"})
+    mutations_registry.is_valid("update", user, payload={"email": "foo@bar.com"})
+
+
+@pytest.mark.parametrize("perm", ["approve", "suggest"])
+def test_permissions(perm, factories, mutations_registry, mocker):
+    actor = factories["federation.Actor"].build()
+    user = factories["users.User"].build()
+
+    class S(mutations.UpdateMutationSerializer):
+        class Meta:
+            model = user.__class__
+            fields = ["email"]
+
+    mutations_registry.connect("update", user.__class__)(S)
+
+    assert mutations_registry.has_perm(perm, "update", obj=user, actor=actor) is False
+
+    checker = mocker.Mock(return_value=True)
+    mutations_registry.connect("update", user.__class__, perm_checkers={perm: checker})(
+        S
+    )
+
+    assert mutations_registry.has_perm(perm, "update", obj=user, actor=actor) is True
+    checker.assert_called_once_with(obj=user, actor=actor)
+
+
+def test_model_apply(factories, mocker, now):
+    target = factories["music.Artist"]()
+    mutation = factories["common.Mutation"](type="noop", target=target, payload="hello")
+
+    apply = mocker.patch.object(
+        mutations.registry, "apply", return_value={"previous": "state"}
+    )
+
+    mutation.apply()
+    apply.assert_called_once_with(type="noop", obj=target, payload="hello")
+    mutation.refresh_from_db()
+
+    assert mutation.is_applied is True
+    assert mutation.previous_state == {"previous": "state"}
+    assert mutation.applied_date == now
+
+
+def test_get_previous_state(factories):
+    obj = factories["music.Track"]()
+    expected = {
+        "title": {"value": obj.title},
+        "album": {"value": obj.album.pk, "repr": str(obj.album)},
+    }
+    assert (
+        mutations.get_update_previous_state(
+            obj, "title", "album", serialized_relations={"album": "pk"}
+        )
+        == expected
+    )
diff --git a/api/tests/common/test_search.py b/api/tests/common/test_search.py
index e5be7bc900f0d215f27909603390dd115fbb68d8..8872298025658d27ec174470ffd193ca6f64c321 100644
--- a/api/tests/common/test_search.py
+++ b/api/tests/common/test_search.py
@@ -1,6 +1,7 @@
 import pytest
 
 from django.db.models import Q
+from django import forms
 
 from funkwhale_api.common import search
 from funkwhale_api.music import models as music_models
@@ -45,6 +46,24 @@ def test_search_config_query(query, expected):
     assert cleaned["search_query"] == expected
 
 
+def test_search_config_query_filter_field_handler():
+    s = search.SearchConfig(
+        filter_fields={"account": {"handler": lambda v: Q(hello="world")}}
+    )
+
+    cleaned = s.clean("account:noop")
+    assert cleaned["filter_query"] == Q(hello="world")
+
+
+def test_search_config_query_filter_field():
+    s = search.SearchConfig(
+        filter_fields={"account": {"to": "noop", "field": forms.BooleanField()}}
+    )
+
+    cleaned = s.clean("account:true")
+    assert cleaned["filter_query"] == Q(noop=True)
+
+
 @pytest.mark.parametrize(
     "query,expected",
     [
diff --git a/api/tests/common/test_tasks.py b/api/tests/common/test_tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..f097c44231af27de220df3889cf7f1db2fe48ea0
--- /dev/null
+++ b/api/tests/common/test_tasks.py
@@ -0,0 +1,65 @@
+import pytest
+
+from funkwhale_api.common import serializers
+from funkwhale_api.common import signals
+from funkwhale_api.common import tasks
+
+
+def test_apply_migration(factories, mocker):
+    mutation = factories["common.Mutation"](payload={})
+    apply = mocker.patch.object(mutation.__class__, "apply")
+    tasks.apply_mutation(mutation_id=mutation.pk)
+
+    apply.assert_called_once_with()
+
+
+def test_broadcast_mutation_created(factories, mocker):
+    mutation = factories["common.Mutation"](payload={})
+    factories["common.Mutation"](payload={}, is_approved=True)
+    group_send = mocker.patch("funkwhale_api.common.channels.group_send")
+    expected = serializers.APIMutationSerializer(mutation).data
+
+    signals.mutation_created.send(sender=None, mutation=mutation)
+    group_send.assert_called_with(
+        "instance_activity",
+        {
+            "type": "event.send",
+            "text": "",
+            "data": {
+                "type": "mutation.created",
+                "mutation": expected,
+                "pending_review_count": 1,
+            },
+        },
+    )
+
+
+def test_broadcast_mutation_updated(factories, mocker):
+    mutation = factories["common.Mutation"](payload={}, is_approved=True)
+    factories["common.Mutation"](payload={})
+    group_send = mocker.patch("funkwhale_api.common.channels.group_send")
+    expected = serializers.APIMutationSerializer(mutation).data
+
+    signals.mutation_updated.send(
+        sender=None, mutation=mutation, old_is_approved=False, new_is_approved=True
+    )
+    group_send.assert_called_with(
+        "instance_activity",
+        {
+            "type": "event.send",
+            "text": "",
+            "data": {
+                "type": "mutation.updated",
+                "mutation": expected,
+                "old_is_approved": False,
+                "new_is_approved": True,
+                "pending_review_count": 1,
+            },
+        },
+    )
+
+
+def test_cannot_apply_already_applied_migration(factories):
+    mutation = factories["common.Mutation"](payload={}, is_applied=True)
+    with pytest.raises(mutation.__class__.DoesNotExist):
+        tasks.apply_mutation(mutation_id=mutation.pk)
diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a03fb429284f7955222a006deb7d055501d25e9
--- /dev/null
+++ b/api/tests/common/test_views.py
@@ -0,0 +1,161 @@
+import pytest
+from django.urls import reverse
+
+from funkwhale_api.common import serializers
+from funkwhale_api.common import signals
+from funkwhale_api.common import tasks
+
+
+def test_can_detail_mutation(logged_in_api_client, factories):
+    mutation = factories["common.Mutation"](payload={})
+    url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid})
+
+    response = logged_in_api_client.get(url)
+
+    expected = serializers.APIMutationSerializer(mutation).data
+
+    assert response.status_code == 200
+    assert response.data == expected
+
+
+def test_can_list_mutations(logged_in_api_client, factories):
+    mutation = factories["common.Mutation"](payload={})
+    url = reverse("api:v1:mutations-list")
+
+    response = logged_in_api_client.get(url)
+
+    expected = serializers.APIMutationSerializer(mutation).data
+
+    assert response.status_code == 200
+    assert response.data["results"] == [expected]
+
+
+def test_can_destroy_mutation_creator(logged_in_api_client, factories):
+    actor = logged_in_api_client.user.create_actor()
+    track = factories["music.Track"]()
+    mutation = factories["common.Mutation"](
+        target=track, type="update", payload={}, created_by=actor
+    )
+    url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+
+
+def test_can_destroy_mutation_not_creator(logged_in_api_client, factories):
+    logged_in_api_client.user.create_actor()
+    track = factories["music.Track"]()
+    mutation = factories["common.Mutation"](type="update", target=track, payload={})
+    url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 403
+
+    mutation.refresh_from_db()
+
+
+def test_can_destroy_mutation_has_perm(logged_in_api_client, factories, mocker):
+    actor = logged_in_api_client.user.create_actor()
+    track = factories["music.Track"]()
+    mutation = factories["common.Mutation"](target=track, type="update", payload={})
+    has_perm = mocker.patch(
+        "funkwhale_api.common.mutations.registry.has_perm", return_value=True
+    )
+    url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+    has_perm.assert_called_once_with(
+        obj=mutation.target, type=mutation.type, perm="approve", actor=actor
+    )
+
+
+@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)])
+def test_can_approve_reject_mutation_with_perm(
+    endpoint, expected, logged_in_api_client, factories, mocker
+):
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+    actor = logged_in_api_client.user.create_actor()
+    track = factories["music.Track"]()
+    mutation = factories["common.Mutation"](target=track, type="update", payload={})
+    has_perm = mocker.patch(
+        "funkwhale_api.common.mutations.registry.has_perm", return_value=True
+    )
+    url = reverse(
+        "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid}
+    )
+
+    response = logged_in_api_client.post(url)
+
+    assert response.status_code == 200
+    has_perm.assert_called_once_with(
+        obj=mutation.target, type=mutation.type, perm="approve", actor=actor
+    )
+
+    if expected:
+        on_commit.assert_any_call(tasks.apply_mutation.delay, mutation_id=mutation.id)
+    mutation.refresh_from_db()
+
+    assert mutation.is_approved == expected
+    assert mutation.approved_by == actor
+
+    on_commit.assert_any_call(
+        signals.mutation_updated.send,
+        mutation=mutation,
+        sender=None,
+        new_is_approved=expected,
+        old_is_approved=None,
+    )
+
+
+@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)])
+def test_cannot_approve_reject_applied_mutation(
+    endpoint, expected, logged_in_api_client, factories, mocker
+):
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+    logged_in_api_client.user.create_actor()
+    track = factories["music.Track"]()
+    mutation = factories["common.Mutation"](
+        target=track, type="update", payload={}, is_applied=True
+    )
+    mocker.patch("funkwhale_api.common.mutations.registry.has_perm", return_value=True)
+    url = reverse(
+        "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid}
+    )
+
+    response = logged_in_api_client.post(url)
+
+    assert response.status_code == 403
+    on_commit.assert_not_called()
+
+    mutation.refresh_from_db()
+
+    assert mutation.is_approved is None
+    assert mutation.approved_by is None
+
+
+@pytest.mark.parametrize("endpoint, expected", [("approve", True), ("reject", False)])
+def test_cannot_approve_reject_without_perm(
+    endpoint, expected, logged_in_api_client, factories, mocker
+):
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+    logged_in_api_client.user.create_actor()
+    track = factories["music.Track"]()
+    mutation = factories["common.Mutation"](target=track, type="update", payload={})
+    mocker.patch("funkwhale_api.common.mutations.registry.has_perm", return_value=False)
+    url = reverse(
+        "api:v1:mutations-{}".format(endpoint), kwargs={"uuid": mutation.uuid}
+    )
+
+    response = logged_in_api_client.post(url)
+
+    assert response.status_code == 403
+    on_commit.assert_not_called()
+
+    mutation.refresh_from_db()
+
+    assert mutation.is_approved is None
+    assert mutation.approved_by is None
diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6b8223d4efe9397710227028703ca6573155983
--- /dev/null
+++ b/api/tests/music/test_mutations.py
@@ -0,0 +1,35 @@
+from funkwhale_api.music import licenses
+
+
+def test_track_license_mutation(factories, now):
+    track = factories["music.Track"](license=None)
+    mutation = factories["common.Mutation"](
+        type="update", target=track, payload={"license": "cc-by-sa-4.0"}
+    )
+    licenses.load(licenses.LICENSES)
+    mutation.apply()
+    track.refresh_from_db()
+
+    assert track.license.code == "cc-by-sa-4.0"
+
+
+def test_track_title_mutation(factories, now):
+    track = factories["music.Track"](title="foo")
+    mutation = factories["common.Mutation"](
+        type="update", target=track, payload={"title": "bar"}
+    )
+    mutation.apply()
+    track.refresh_from_db()
+
+    assert track.title == "bar"
+
+
+def test_track_position_mutation(factories):
+    track = factories["music.Track"](position=4)
+    mutation = factories["common.Mutation"](
+        type="update", target=track, payload={"position": 12}
+    )
+    mutation.apply()
+    track.refresh_from_db()
+
+    assert track.position == 12
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 741fe9b29cd62f749aba0addf41e5cfdff60303c..b11f9b0065a4b42c84e49473cb3d88cbf57ed266 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -70,6 +70,19 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client):
     assert response.data == expected
 
 
+def test_track_list_filter_id(api_request, factories, logged_in_api_client):
+    track1 = factories["music.Track"]()
+    track2 = factories["music.Track"]()
+    factories["music.Track"]()
+    url = reverse("api:v1:tracks-list")
+    response = logged_in_api_client.get(url, {"id[]": [track1.id, track2.id]})
+
+    assert response.status_code == 200
+    assert response.data["count"] == 2
+    assert response.data["results"][0]["id"] == track2.id
+    assert response.data["results"][1]["id"] == track1.id
+
+
 @pytest.mark.parametrize("param,expected", [("true", "full"), ("false", "empty")])
 def test_artist_view_filter_playable(param, expected, factories, api_request):
     artists = {
diff --git a/front/package.json b/front/package.json
index adeb1fb19d6bc64a4bee8b1f099245fc285875db..22b5f3bb5ed35aebd5cb562c5f752ec2a65f8533 100644
--- a/front/package.json
+++ b/front/package.json
@@ -13,6 +13,7 @@
   "dependencies": {
     "axios": "^0.18.0",
     "dateformat": "^3.0.3",
+    "diff": "^4.0.1",
     "django-channels": "^1.1.6",
     "howler": "^2.0.14",
     "js-logger": "^1.4.1",
diff --git a/front/src/App.vue b/front/src/App.vue
index 1cc6a2d3cadaa313a7876016a5f33674a7d4f2e7..fd94a9f4605dc69d386d864f3663c5d928e8e4e8 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -92,6 +92,16 @@ export default {
       id: 'sidebarCount',
       handler: this.incrementNotificationCountInSidebar
     })
+    this.$store.commit('ui/addWebsocketEventHandler', {
+      eventName: 'mutation.created',
+      id: 'sidebarReviewEditCount',
+      handler: this.incrementReviewEditCountInSidebar
+    })
+    this.$store.commit('ui/addWebsocketEventHandler', {
+      eventName: 'mutation.updated',
+      id: 'sidebarReviewEditCount',
+      handler: this.incrementReviewEditCountInSidebar
+    })
   },
   mounted () {
     let self = this
@@ -110,12 +120,23 @@ export default {
       eventName: 'inbox.item_added',
       id: 'sidebarCount',
     })
+    this.$store.commit('ui/removeWebsocketEventHandler', {
+      eventName: 'mutation.created',
+      id: 'sidebarReviewEditCount',
+    })
+    this.$store.commit('ui/removeWebsocketEventHandler', {
+      eventName: 'mutation.updated',
+      id: 'sidebarReviewEditCount',
+    })
     this.disconnect()
   },
   methods: {
     incrementNotificationCountInSidebar (event) {
       this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1})
     },
+    incrementReviewEditCountInSidebar (event) {
+      this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count})
+    },
     fetchNodeInfo () {
       let self = this
       axios.get('instance/nodeinfo/2.0/').then(response => {
@@ -179,7 +200,6 @@ export default {
     }),
     suggestedInstances () {
       let instances = this.$store.state.instance.knownInstances.slice(0)
-      console.log('instance', instances)
       if (this.$store.state.instance.frontSettings.defaultServerUrl) {
         let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
         if (!serverUrl.endsWith('/')) {
@@ -188,7 +208,6 @@ export default {
         instances.push(serverUrl)
       }
       instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
-      console.log('HELLO', instances)
       return _.uniq(instances.filter((e) => {return e}))
     },
     version () {
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 865618b12cc8ffd58dec9f50e35b1f15d82e4a3c..77b9c02f5eb3e74db492acdebd648b7a35f48e91 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -97,6 +97,17 @@
               :to="{name: 'manage.moderation.domains.list'}">
               <i class="shield icon"></i><translate>Moderation</translate>
             </router-link>
+            <router-link
+              v-if="$store.state.auth.availablePermissions['library']"
+              class="item"
+              :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}">
+              <i class="book icon"></i><translate>Library</translate>
+              <div
+                v-if="$store.state.ui.notifications.pendingReviewEdits > 0"
+                :title="labels.pendingReviewEdits"
+                :class="['ui', 'teal', 'label']">
+                {{ $store.state.ui.notifications.pendingReviewEdits }}</div>
+            </router-link>
           </div>
         </div>
       </nav>
@@ -210,10 +221,12 @@ export default {
       let mainMenu = this.$gettext("Main menu")
       let selectTrack = this.$gettext("Play this track")
       let pendingFollows = this.$gettext("Pending follow requests")
+      let pendingReviewEdits = this.$gettext("Pending review edits")
       return {
         pendingFollows,
         mainMenu,
-        selectTrack
+        selectTrack,
+        pendingReviewEdits
       }
     },
     tracks: {
diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue
index 61be521b5d24a5cab6f2c9226453fda48134cdf3..6a1b65cd3aca0ce9ec3ed3ff017150ae3b1ee211 100644
--- a/front/src/components/auth/Login.vue
+++ b/front/src/components/auth/Login.vue
@@ -97,7 +97,6 @@ export default {
         username: this.credentials.username,
         password: this.credentials.password
       }
-      console.log('NEXT', this.next)
       this.$store
         .dispatch("auth/login", {
           credentials,
diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ddd9066f031d24a592984b1cd1e7a4c2ea983f31
--- /dev/null
+++ b/front/src/components/common/EmptyState.vue
@@ -0,0 +1,40 @@
+<template>
+  <div class="ui small placeholder segment">
+    <div class="ui header">
+      <div class="content">
+        <slot name="title">
+
+          <i class="search icon"></i>
+          <translate :translate-context="'Content/*/Paragraph'">
+            No results were found.
+          </translate>
+        </slot>
+      </div>
+    </div>
+    <div class="inline">
+      <slot></slot>
+      <button v-if="refresh" class="ui button" @click="$emit('refresh')">
+        <translate :translate-context="'Content/Button/Label/Verb'">
+          Refresh
+        </translate></button>
+      </button>
+    </div>
+  </div>
+</template>
+<script>
+export default {
+  props: {
+    refresh: {type: Boolean, default: false}
+  }
+}
+</script>
+
+<style>
+.ui.small.placeholder.segment {
+  min-height: auto;
+}
+.ui.header .content {
+  text-align: center;
+  display: block;
+}
+</style>
diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue
index eed245ea6ce1538d9b0afcd9bcd6297e5695f3a7..fde04f141399428ff413154775ce51eada0d0623 100644
--- a/front/src/components/common/HumanDate.vue
+++ b/front/src/components/common/HumanDate.vue
@@ -1,10 +1,16 @@
 <template>
-  <time :datetime="date" :title="date | moment">{{ realDate | ago($store.state.ui.momentLocale) }}</time>
+  <time :datetime="date" :title="date | moment">
+    <i v-if="icon" class="outline clock icon"></i>
+    {{ realDate | ago($store.state.ui.momentLocale) }}
+  </time>
 </template>
 <script>
 import {mapState} from 'vuex'
 export default {
-  props: ['date'],
+  props: {
+    date: {required: true},
+    icon: {type: Boolean, required: false, default: false},
+  },
   computed: {
     ...mapState({
       lastDate: state => state.ui.lastDate
diff --git a/front/src/components/globals.js b/front/src/components/globals.js
index 99e57095c0735a96c55d99e8fd00827203eaa929..711b227ae956a8943c8085087b0215029a50499d 100644
--- a/front/src/components/globals.js
+++ b/front/src/components/globals.js
@@ -44,5 +44,8 @@ import Tooltip from '@/components/common/Tooltip'
 
 Vue.component('tooltip', Tooltip)
 
+import EmptyState from '@/components/common/EmptyState'
+
+Vue.component('empty-state', EmptyState)
 
 export default {}
diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..88a512373bbcff4643e1d1127d2a155787191f7d
--- /dev/null
+++ b/front/src/components/library/EditCard.vue
@@ -0,0 +1,209 @@
+<template>
+  <div class="ui fluid card">
+    <div class="content">
+      <div class="header">
+        <router-link :to="detailUrl">
+          <translate :translate-context="'Content/Library/Card/Short'" :translate-params="{id: obj.uuid.substring(0, 8)}">Modification %{ id }</translate>
+        </router-link>
+      </div>
+      <div class="meta">
+        <router-link
+          v-if="obj.target && obj.target.type === 'track'"
+          :to="{name: 'library.tracks.detail', params: {id: obj.target.id }}">
+          <i class="music icon"></i>
+          <translate :translate-context="'Content/Library/Card/Short'" :translate-params="{id: obj.target.id, name: obj.target.repr}">Track #%{ id } - %{ name }</translate>
+        </router-link>
+        <br>
+        <human-date :date="obj.creation_date" :icon="true"></human-date>
+
+        <span class="right floated">
+          <span v-if="obj.is_approved && obj.is_applied">
+            <i class="green check icon"></i>
+            <translate :translate-context="'Content/Library/Card/Short'">Approved and applied</translate>
+          </span>
+          <span v-else-if="obj.is_approved">
+            <i class="green check icon"></i>
+            <translate :translate-context="'Content/Library/Card/Short'">Approved</translate>
+          </span>
+          <span v-else-if="obj.is_approved === null">
+            <i class="yellow hourglass icon"></i>
+            <translate :translate-context="'Content/Library/Card/Short'">Pending review</translate>
+          </span>
+          <span v-else-if="obj.is_approved === false">
+            <i class="red x icon"></i>
+            <translate :translate-context="'Content/Library/Card/Short'">Rejected</translate>
+          </span>
+        </span>
+      </div>
+    </div>
+    <div v-if="obj.summary" class="content">
+      {{ obj.summary }}
+    </div>
+    <div class="content">
+      <table v-if="obj.type === 'update'" class="ui celled very basic fixed stacking table">
+        <thead>
+          <tr>
+            <th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">Field</translate></th>
+            <th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">Old value</translate></th>
+            <th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">New value</translate></th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="field in getUpdatedFields(obj.payload, previousState)" :key="field.id">
+            <td>{{ field.id }}</td>
+
+            <td v-if="field.diff">
+              <span v-if="!part.added" v-for="part in field.diff" :class="['diff', {removed: part.removed}]">
+                {{ part.value }}
+              </span>
+            </td>
+            <td v-else>
+              <translate :translate-context="'*/*/*'">N/A</translate>
+            </td>
+
+            <td v-if="field.diff">
+              <span v-if="!part.removed" v-for="part in field.diff" :class="['diff', {added: part.added}]">
+                {{ part.value }}
+              </span>
+            </td>
+            <td v-else>{{ field.new }}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+    <div v-if="obj.created_by" class="extra content">
+      <actor-link :actor="obj.created_by" />
+    </div>
+    <div v-if="canDelete || canApprove" class="ui bottom attached buttons">
+      <button
+        v-if="canApprove && obj.is_approved !== true"
+        @click="approve(true)"
+        :class="['ui', {loading: isLoading}, 'green', 'basic', 'button']">
+        <translate :translate-context="'Content/Library/Button.Label'">Approve</translate>
+      </button>
+      <button
+        v-if="canApprove && obj.is_approved === null"
+        @click="approve(false)"
+        :class="['ui', {loading: isLoading}, 'yellow', 'basic', 'button']">
+        <translate :translate-context="'Content/Library/Button.Label'">Reject</translate>
+      </button>
+      <dangerous-button
+        v-if="canDelete"
+        :class="['ui', {loading: isLoading}, 'basic button']"
+        :action="remove">
+        <translate :translate-context="'*/*/*/Verb'">Delete</translate>
+        <p slot="modal-header"><translate :translate-context="'Popup/Library/Title'">Delete this suggestion?</translate></p>
+        <div slot="modal-content">
+          <p><translate :translate-context="'Popup/Library/Paragraph'">The suggestion will be completely removed, this action is irreversible.</translate></p>
+        </div>
+        <p slot="modal-confirm"><translate :translate-context="'Popup/Library/Button.Label'">Delete</translate></p>
+      </dangerous-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import { diffWordsWithSpace } from 'diff'
+
+import edits from '@/edits'
+
+function castValue (value) {
+  if (value === null || value === undefined) {
+    return ''
+  }
+  return String(value)
+}
+
+export default {
+  props: {
+    obj: {required: true},
+    currentState: {required: false}
+  },
+  data () {
+    return {
+      isLoading: false
+    }
+  },
+  computed: {
+    canApprove: edits.getCanApprove,
+    canDelete: edits.getCanDelete,
+    previousState () {
+      if (this.obj.is_applied) {
+        // mutation was applied, we use the previous state that is stored
+        // on the mutation itself
+        return this.obj.previous_state
+      }
+      // mutation is not applied yet, so we use the current state that was
+      // passed to the component, if any
+      return this.currentState
+    },
+    detailUrl () {
+      if (!this.obj.target) {
+        return ''
+      }
+      let namespace
+      let id = this.obj.target.id
+      if (this.obj.target.type === 'track') {
+        namespace = 'library.tracks.edit.detail'
+      }
+      if (this.obj.target.type === 'album') {
+        namespace = 'library.albums.edit.detail'
+      }
+      if (this.obj.target.type === 'artist') {
+        namespace = 'library.artists.edit.detail'
+      }
+      return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href
+    }
+  },
+  methods: {
+    remove () {
+      let self = this
+      this.isLoading = true
+      axios.delete(`mutations/${this.obj.uuid}/`).then((response) => {
+        self.$emit('deleted')
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+      })
+    },
+    approve (approved) {
+      let url
+      if (approved) {
+        url = `mutations/${this.obj.uuid}/approve/`
+      } else {
+        url = `mutations/${this.obj.uuid}/reject/`
+      }
+      let self = this
+      this.isLoading = true
+      axios.post(url).then((response) => {
+        self.$emit('approved', approved)
+        self.isLoading = false
+        self.$store.commit('ui/incrementNotifications', {count: -1, type: 'pendingReviewEdits'})
+      }, error => {
+        self.isLoading = false
+      })
+    },
+    getUpdatedFields (payload, previousState) {
+      let fields = Object.keys(payload)
+      return fields.map((f) => {
+        let d = {
+          id: f,
+        }
+        if (previousState && previousState[f]) {
+          d.old = previousState[f]
+        }
+        d.new = payload[f]
+        if (d.old) {
+          // we compute the diffs between the old and new values
+
+          let oldValue = castValue(d.old.value)
+          let newValue = castValue(d.new)
+          d.diff = diffWordsWithSpace(oldValue, newValue)
+        }
+        return d
+      })
+    }
+  }
+}
+</script>
diff --git a/front/src/components/library/EditDetail.vue b/front/src/components/library/EditDetail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4a0c89434de8797d37022d0d9916df1fd719647a
--- /dev/null
+++ b/front/src/components/library/EditDetail.vue
@@ -0,0 +1,52 @@
+<template>
+
+  <section :class="['ui', 'vertical', 'stripe', {loading: isLoading}, 'segment']">
+    <div class="ui text container">
+      <edit-card v-if="obj" :obj="obj" :current-state="currentState" />
+    </div>
+  </section>
+</template>
+
+<script>
+import axios from "axios"
+import edits from '@/edits'
+import EditCard from '@/components/library/EditCard'
+export default {
+  props: ["object", "objectType", "editId"],
+  components: {
+    EditCard
+  },
+  data () {
+    return {
+      isLoading: true,
+      obj: null,
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  computed: {
+    configs: edits.getConfigs,
+    config: edits.getConfig,
+    currentState: edits.getCurrentState,
+    currentState () {
+      let self = this
+      let s = {}
+      this.config.fields.forEach(f => {
+        s[f.id] = {value: f.getValue(self.object)}
+      })
+      return s
+    }
+  },
+  methods: {
+    fetchData () {
+      var self = this
+      this.isLoading = true
+      axios.get(`mutations/${this.editId}/`).then(response => {
+        self.obj = response.data
+        self.isLoading = false
+      })
+    }
+  }
+}
+</script>
diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e98a4a6567d749d9a4e92a21a9686f38d37ef45c
--- /dev/null
+++ b/front/src/components/library/EditForm.vue
@@ -0,0 +1,192 @@
+<template>
+  <div v-if="submittedMutation">
+    <div class="ui positive message">
+      <div class="header"><translate :translate-context="'Content/Library/Paragraph'">Your edit was successfully submitted.</translate></div>
+    </div>
+    <edit-card :obj="submittedMutation" :current-state="currentState" />
+    <button class="ui button" @click.prevent="submittedMutation = null">
+      <translate :translate-context="'Content/Library/Button.Label'">
+        Submit another edit
+      </translate>
+    </button>
+  </div>
+  <div v-else>
+
+    <edit-list :filters="editListFilters" :url="mutationsUrl" :obj="object" :currentState="currentState">
+      <div slot="title">
+        <template v-if="showPendingReview">
+          <translate :translate-context="'Content/Library/Paragraph'">
+            Recent edits awaiting review
+          </translate>
+          <button class="ui tiny basic right floated button" @click.prevent="showPendingReview = false">
+            <translate :translate-context="'Content/Library/Button.Label'">
+              Show all edits
+            </translate>
+          </button>
+        </template>
+        <template v-else>
+          <translate :translate-context="'Content/Library/Paragraph'">
+            Recent edits
+          </translate>
+          <button class="ui tiny basic right floated button" @click.prevent="showPendingReview = true">
+            <translate :translate-context="'Content/Library/Button.Label'">
+              Retrict to unreviewed edits
+            </translate>
+          </button>
+        </template>
+      </div>
+      <empty-state slot="empty-state">
+        <translate :translate-context="'Content/Library/Paragraph'">
+          Suggest a change using the form below.
+        </translate>
+      </empty-state>
+    </edit-list>
+    <form class="ui form" @submit.prevent="submit()">
+      <div class="ui hidden divider"></div>
+      <div v-if="errors.length > 0" class="ui negative message">
+        <div class="header"><translate :translate-context="'Content/Library/Error message.Title'">Error while submitting edit</translate></div>
+        <ul class="list">
+          <li v-for="error in errors">{{ error }}</li>
+        </ul>
+      </div>
+      <div v-if="!canEdit" class="ui message">
+        <translate :translate-context="'Content/Library/Paragraph'">
+          You don't have the permission to edit this object, but you can suggest changes. Once submitted, suggestions will be reviewed before approval.
+        </translate>
+      </div>
+      <div v-if="values" v-for="fieldConfig in config.fields" :key="fieldConfig.id" class="ui field">
+        <template v-if="fieldConfig.type === 'text'">
+          <label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
+          <input :type="fieldConfig.inputType || 'text'" v-model="values[fieldConfig.id]" :required="fieldConfig.required" :name="fieldConfig.id" :id="fieldConfig.id">
+        </template>
+        <div v-if="values[fieldConfig.id] != initialValues[fieldConfig.id]">
+          <button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = initialValues[fieldConfig.id]">
+            <i class="undo icon"></i>
+            <translate :translate-context="'Content/Library/Button.Label'" :translate-params="{value: initialValues[fieldConfig.id]}">Reset to initial value: %{ value }</translate>
+          </button>
+        </div>
+      </div>
+      <div class="field">
+        <label for="summary"><translate :translate-context="'*/*/*'">Summary (optional)</translate></label>
+        <textarea name="change-summary" v-model="summary" id="change-summary" rows="3" :placeholder="labels.summaryPlaceholder"></textarea>
+      </div>
+      <router-link
+        class="ui left floated button"
+        v-if="objectType === 'track'"
+        :to="{name: 'library.tracks.detail', params: {id: object.id }}"
+      >
+        <translate :translate-context="'Content/*/Button.Label'">Cancel</translate>
+      </router-link>
+      <button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit" :disabled="isLoading || !mutationPayload">
+        <translate v-if="canEdit" key="1" :translate-context="'Content/Library/Button.Label/Verb'">Submit and apply edit</translate>
+        <translate v-else key="2" :translate-context="'Content/Library/Button.Label/Verb'">Submit suggestion</translate>
+      </button>
+      </form>
+    </div>
+  </div>
+</template>
+
+<script>
+import _ from '@/lodash'
+import axios from "axios"
+import EditList from '@/components/library/EditList'
+import EditCard from '@/components/library/EditCard'
+import edits from '@/edits'
+
+export default {
+  props: ["objectType", "object"],
+  components: {
+    EditList,
+    EditCard
+  },
+  data() {
+    return {
+      isLoading: false,
+      errors: [],
+      values: {},
+      initialValues: {},
+      summary: '',
+      submittedMutation: null,
+      showPendingReview: true,
+    }
+  },
+  created () {
+    this.setValues()
+  },
+  computed: {
+    configs: edits.getConfigs,
+    config: edits.getConfig,
+    currentState: edits.getCurrentState,
+    canEdit: edits.getCanEdit,
+    labels () {
+      return {
+        summaryPlaceholder: this.$pgettext('*/*/Placeholder', 'A short summary describing your changes.'),
+      }
+    },
+    mutationsUrl () {
+      if (this.objectType === 'track') {
+        return `tracks/${this.object.id}/mutations/`
+      }
+    },
+    mutationPayload () {
+      let self = this
+      let changedFields = this.config.fields.filter(f => {
+        return self.values[f.id] != self.initialValues[f.id]
+      })
+      if (changedFields.length === 0) {
+        return null
+      }
+      let payload = {
+        type: 'update',
+        payload: {},
+        summary: this.summary,
+      }
+      changedFields.forEach((f) => {
+        payload.payload[f.id] = self.values[f.id]
+      })
+      return payload
+    },
+    editListFilters () {
+      if (this.showPendingReview) {
+        return {is_approved: 'null'}
+      } else {
+        return {}
+      }
+    },
+  },
+
+  methods: {
+    setValues () {
+      let self = this
+      this.config.fields.forEach(f => {
+        self.$set(self.values, f.id, f.getValue(self.object))
+        self.$set(self.initialValues, f.id, self.values[f.id])
+      })
+    },
+    submit() {
+      let self = this
+      self.isLoading = true
+      self.errors = []
+      let payload = _.clone(this.mutationPayload || {})
+      if (this.canEdit) {
+        payload.is_approved = true
+      }
+      return axios.post(this.mutationsUrl, payload).then(
+        response => {
+          self.isLoading = false
+          self.submittedMutation = response.data
+        },
+        error => {
+          self.errors = error.backendErrors
+          self.isLoading = false
+        }
+      )
+    }
+  }
+}
+</script>
+<style>
+.reset.button {
+  margin-top: 0.5em;
+}
+</style>
diff --git a/front/src/components/library/EditList.vue b/front/src/components/library/EditList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2ff1fc72a0661e0e9a21c3e97e17fe73aabd1b39
--- /dev/null
+++ b/front/src/components/library/EditList.vue
@@ -0,0 +1,74 @@
+<template>
+  <div class="wrapper">
+    <h3 class="ui header">
+      <slot name="title"></slot>
+    </h3>
+    <slot v-if="!isLoading && objects.length === 0" name="empty-state"></slot>
+    <button v-if="nextPage || previousPage" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
+    <button v-if="nextPage || previousPage" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
+    <div class="ui hidden divider"></div>
+    <div v-if="isLoading" class="ui inverted active dimmer">
+      <div class="ui loader"></div>
+    </div>
+    <edit-card @updated="fetchData(url)" @deleted="fetchData(url)" v-for="obj in objects" :key="obj.uuid" :obj="obj" :current-state="currentState" />
+  </div>
+</template>
+
+<script>
+import _ from '@/lodash'
+import axios from 'axios'
+
+import EditCard from '@/components/library/EditCard'
+
+export default {
+  props: {
+    url: {type: String, required: true},
+    filters: {type: Object, required: false, default: () => {return {}}},
+    currentState: {required: false},
+  },
+  components: {
+    EditCard
+  },
+  data () {
+    return {
+      objects: [],
+      limit: 5,
+      isLoading: false,
+      errors: null,
+      previousPage: null,
+      nextPage: null
+    }
+  },
+  created () {
+    this.fetchData(this.url)
+  },
+  methods: {
+    fetchData (url) {
+      if (!url) {
+        return
+      }
+      this.isLoading = true
+      let self = this
+      let params = _.clone(this.filters)
+      params.page_size = this.limit
+      axios.get(url, {params: params}).then((response) => {
+        self.previousPage = response.data.previous
+        self.nextPage = response.data.next
+        self.isLoading = false
+        self.objects = response.data.results
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+  },
+  watch: {
+    filters: {
+      handler () {
+        this.fetchData(this.url)
+      },
+      deep: true
+    }
+  }
+}
+</script>
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/TrackBase.vue
similarity index 51%
rename from front/src/components/library/Track.vue
rename to front/src/components/library/TrackBase.vue
index 66b17d04b1cba7e378a607565736942a634d530e..c0209732df3698c5bee50b75194de52e339989d0 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/TrackBase.vue
@@ -64,99 +64,15 @@
               </div>
             </modal>
           </template>
+          <router-link
+            :to="{name: 'library.tracks.edit', params: {id: track.id }}"
+            class="ui icon labeled button">
+            <i class="edit icon"></i>
+            <translate :translate-context="'Content/Track/Button.Label/Verb'">Edit…</translate>
+          </router-link>
         </div>
       </section>
-      <section class="ui vertical stripe center aligned segment">
-        <h2 class="ui header">
-          <translate :translate-context="'Content/Track/Title/Noun'">Track information</translate>
-        </h2>
-        <table class="ui very basic collapsing celled center aligned table">
-          <tbody>
-            <tr>
-              <td>
-                <translate :translate-context="'Content/Track/Table.Label/Noun'">Copyright</translate>
-              </td>
-              <td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td>
-              <td v-else>
-                <translate :translate-context="'Content/Track/Table.Paragraph'">No copyright information available for this track</translate>
-              </td>
-            </tr>
-            <tr>
-              <td>
-                <translate :translate-context="'Content/Track/Table.Label/Noun'">License</translate>
-              </td>
-              <td v-if="license">
-                <a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a>
-              </td>
-              <td v-else>
-                <translate :translate-context="'Content/Track/Table.Paragraph'">No licensing information for this track</translate>
-              </td>
-            </tr>
-            <tr>
-              <td>
-                <translate :translate-context="'Content/Track/Table.Label'">Duration</translate>
-              </td>
-              <td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td>
-              <td v-else>
-                <translate :translate-context="'*/*/*'">N/A</translate>
-              </td>
-            </tr>
-            <tr>
-              <td>
-                <translate :translate-context="'Content/Track/Table.Label'">Size</translate>
-              </td>
-              <td v-if="upload && upload.size">{{ upload.size | humanSize }}</td>
-              <td v-else>
-                <translate :translate-context="'*/*/*'">N/A</translate>
-              </td>
-            </tr>
-            <tr>
-              <td>
-                <translate :translate-context="'Content/Track/Table.Label'">Bitrate</translate>
-              </td>
-              <td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td>
-              <td v-else>
-                <translate :translate-context="'*/*/*'">N/A</translate>
-              </td>
-            </tr>
-            <tr>
-              <td>
-                <translate :translate-context="'Content/Track/Table.Label/Noun'">Type</translate>
-              </td>
-              <td v-if="upload && upload.extension">{{ upload.extension }}</td>
-              <td v-else>
-                <translate :translate-context="'*/*/*'">N/A</translate>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </section>
-      <section class="ui vertical stripe center aligned segment">
-        <h2>
-          <translate :translate-context="'Content/Track/Title'">Lyrics</translate>
-        </h2>
-        <div v-if="isLoadingLyrics" class="ui vertical segment">
-          <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-        </div>
-        <div v-if="lyrics" v-html="lyrics.content_rendered"></div>
-        <template v-if="!isLoadingLyrics & !lyrics">
-          <p>
-            <translate :translate-context="'Content/Track/Paragraph'">No lyrics available for this track.</translate>
-          </p>
-          <a class="ui button" target="_blank" :href="lyricsSearchUrl">
-            <i class="search icon"></i>
-            <translate :translate-context="'Content/Track/Link/Verb'">Search on lyrics.wikia.com</translate>
-          </a>
-        </template>
-      </section>
-      <section class="ui vertical stripe segment">
-        <h2>
-          <translate :translate-context="'Content/Track/Title'">User libraries</translate>
-        </h2>
-        <library-widget @loaded="libraries = $event" :url="'tracks/' + id + '/libraries/'">
-          <translate :translate-context="'Content/Track/Paragraph'" slot="subtitle">This track is present in the following libraries:</translate>
-        </library-widget>
-      </section>
+      <router-view v-if="track" @libraries-loaded="libraries = $event" :track="track" :object="track" object-type="track" :key="$route.fullPath"></router-view>
     </template>
   </main>
 </template>
@@ -169,7 +85,6 @@ import logger from "@/logging"
 import PlayButton from "@/components/audio/PlayButton"
 import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
 import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
-import LibraryWidget from "@/components/federation/LibraryWidget"
 import Modal from '@/components/semantic/Modal'
 import EmbedWizard from "@/components/audio/EmbedWizard"
 
@@ -181,7 +96,6 @@ export default {
     PlayButton,
     TrackPlaylistIcon,
     TrackFavoriteIcon,
-    LibraryWidget,
     Modal,
     EmbedWizard
   },
@@ -189,17 +103,13 @@ export default {
     return {
       time,
       isLoadingTrack: true,
-      isLoadingLyrics: true,
       track: null,
-      lyrics: null,
-      licenseData: null,
-      libraries: [],
-      showEmbedModal: false
+      showEmbedModal: false,
+      libraries: []
     }
   },
   created() {
     this.fetchData()
-    this.fetchLyrics()
   },
   methods: {
     fetchData() {
@@ -212,29 +122,6 @@ export default {
         self.isLoadingTrack = false
       })
     },
-    fetchLicenseData(licenseId) {
-      var self = this
-      let url = `licenses/${licenseId}/`
-      axios.get(url).then(response => {
-        self.licenseData = response.data
-      })
-    },
-    fetchLyrics() {
-      var self = this
-      this.isLoadingLyrics = true
-      let url = FETCH_URL + this.id + "/lyrics/"
-      logger.default.debug('Fetching lyrics for track "' + this.id + '"')
-      axios.get(url).then(
-        response => {
-          self.lyrics = response.data
-          self.isLoadingLyrics = false
-        },
-        response => {
-          console.error("No lyrics available")
-          self.isLoadingLyrics = false
-        }
-      )
-    }
   },
   computed: {
     publicLibraries () {
@@ -242,16 +129,16 @@ export default {
         return l.privacy_level === 'everyone'
       })
     },
-    labels() {
-      return {
-        title: this.$pgettext('Head/Track/Title', "Track")
-      }
-    },
     upload() {
       if (this.track.uploads) {
         return this.track.uploads[0]
       }
     },
+    labels() {
+      return {
+        title: this.$pgettext('Head/Track/Title', "Track")
+      }
+    },
     wikipediaUrl() {
       return (
         "https://en.wikipedia.org/w/index.php?search=" +
@@ -276,11 +163,6 @@ export default {
       }
       return u
     },
-    lyricsSearchUrl() {
-      let base = "http://lyrics.wikia.com/wiki/Special:Search?query="
-      let query = this.track.artist.name + ":" + this.track.title
-      return base + encodeURI(query)
-    },
     cover() {
       return null
     },
@@ -302,30 +184,11 @@ export default {
         ")"
       )
     },
-    license() {
-      if (!this.track || !this.track.license) {
-        return null
-      }
-      return this.licenseData
-    }
   },
   watch: {
     id() {
       this.fetchData()
     },
-    track (v) {
-      if (v && v.license) {
-        this.fetchLicenseData(v.license)
-      }
-    }
   }
 }
 </script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-.table.center.aligned {
-  margin-left: auto;
-  margin-right: auto;
-}
-</style>
diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7f6d27dc1316aaba9fffa6d9890f73edbf86737f
--- /dev/null
+++ b/front/src/components/library/TrackDetail.vue
@@ -0,0 +1,191 @@
+<template>
+
+  <div v-if="track">
+    <section class="ui vertical stripe center aligned segment">
+      <h2 class="ui header">
+        <translate :translate-context="'Content/Track/Title/Noun'">Track information</translate>
+      </h2>
+      <table class="ui very basic collapsing celled center aligned table">
+        <tbody>
+          <tr>
+            <td>
+              <translate :translate-context="'Content/Track/Table.Label/Noun'">Copyright</translate>
+            </td>
+            <td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td>
+            <td v-else>
+              <translate :translate-context="'Content/Track/Table.Paragraph'">No copyright information available for this track</translate>
+            </td>
+          </tr>
+          <tr>
+            <td>
+              <translate :translate-context="'Content/Track/Table.Label/Noun'">License</translate>
+            </td>
+            <td v-if="license">
+              <a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a>
+            </td>
+            <td v-else>
+              <translate :translate-context="'Content/Track/Table.Paragraph'">No licensing information for this track</translate>
+            </td>
+          </tr>
+          <tr>
+            <td>
+              <translate :translate-context="'Content/Track/Table.Label'">Duration</translate>
+            </td>
+            <td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td>
+            <td v-else>
+              <translate :translate-context="'*/*/*'">N/A</translate>
+            </td>
+          </tr>
+          <tr>
+            <td>
+              <translate :translate-context="'Content/Track/Table.Label'">Size</translate>
+            </td>
+            <td v-if="upload && upload.size">{{ upload.size | humanSize }}</td>
+            <td v-else>
+              <translate :translate-context="'*/*/*'">N/A</translate>
+            </td>
+          </tr>
+          <tr>
+            <td>
+              <translate :translate-context="'Content/Track/Table.Label'">Bitrate</translate>
+            </td>
+            <td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td>
+            <td v-else>
+              <translate :translate-context="'*/*/*'">N/A</translate>
+            </td>
+          </tr>
+          <tr>
+            <td>
+              <translate :translate-context="'Content/Track/Table.Label/Noun'">Type</translate>
+            </td>
+            <td v-if="upload && upload.extension">{{ upload.extension }}</td>
+            <td v-else>
+              <translate :translate-context="'*/*/*'">N/A</translate>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </section>
+    <section class="ui vertical stripe center aligned segment">
+      <h2>
+        <translate :translate-context="'Content/Track/Title'">Lyrics</translate>
+      </h2>
+      <div v-if="isLoadingLyrics" class="ui vertical segment">
+        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+      </div>
+      <div v-if="lyrics" v-html="lyrics.content_rendered"></div>
+      <template v-if="!isLoadingLyrics & !lyrics">
+        <p>
+          <translate :translate-context="'Content/Track/Paragraph'">No lyrics available for this track.</translate>
+        </p>
+        <a class="ui button" target="_blank" :href="lyricsSearchUrl">
+          <i class="search icon"></i>
+          <translate :translate-context="'Content/Track/Link/Verb'">Search on lyrics.wikia.com</translate>
+        </a>
+      </template>
+    </section>
+    <section class="ui vertical stripe segment">
+      <h2>
+        <translate :translate-context="'Content/Track/Title'">User libraries</translate>
+      </h2>
+      <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'tracks/' + id + '/libraries/'">
+        <translate :translate-context="'Content/Track/Paragraph'" slot="subtitle">This track is present in the following libraries:</translate>
+      </library-widget>
+    </section>
+  </div>
+</template>
+
+<script>
+import time from "@/utils/time"
+import axios from "axios"
+import url from "@/utils/url"
+import logger from "@/logging"
+import LibraryWidget from "@/components/federation/LibraryWidget"
+
+const FETCH_URL = "tracks/"
+
+export default {
+  props: ["track", "libraries"],
+  components: {
+    LibraryWidget,
+  },
+  data() {
+    return {
+      time,
+      id: this.track.id,
+      isLoadingLyrics: true,
+      lyrics: null,
+      licenseData: null
+    }
+  },
+  created() {
+    this.fetchLyrics()
+    if (this.track && this.track.license) {
+      this.fetchLicenseData(this.track.license)
+    }
+  },
+  methods: {
+    fetchLicenseData(licenseId) {
+      var self = this
+      let url = `licenses/${licenseId}/`
+      axios.get(url).then(response => {
+        self.licenseData = response.data
+      })
+    },
+    fetchLyrics() {
+      var self = this
+      this.isLoadingLyrics = true
+      let url = FETCH_URL + this.id + "/lyrics/"
+      logger.default.debug('Fetching lyrics for track "' + this.id + '"')
+      axios.get(url).then(
+        response => {
+          self.lyrics = response.data
+          self.isLoadingLyrics = false
+        },
+        response => {
+          console.error("No lyrics available")
+          self.isLoadingLyrics = false
+        }
+      )
+    }
+  },
+  computed: {
+    labels() {
+      return {
+        title: this.$pgettext('Head/Track/Title', "Track")
+      }
+    },
+    upload() {
+      if (this.track.uploads) {
+        return this.track.uploads[0]
+      }
+    },
+    lyricsSearchUrl() {
+      let base = "http://lyrics.wikia.com/wiki/Special:Search?query="
+      let query = this.track.artist.name + ":" + this.track.title
+      return base + encodeURI(query)
+    },
+    license() {
+      if (!this.track || !this.track.license) {
+        return null
+      }
+      return this.licenseData
+    }
+  },
+  watch: {
+    track (v) {
+      if (v && v.license) {
+        this.fetchLicenseData(v.license)
+      }
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+.table.center.aligned {
+  margin-left: auto;
+  margin-right: auto;
+}
+</style>
diff --git a/front/src/components/library/TrackEdit.vue b/front/src/components/library/TrackEdit.vue
new file mode 100644
index 0000000000000000000000000000000000000000..40178e6ed25968e1cc718fa6d479ae0bcd0db016
--- /dev/null
+++ b/front/src/components/library/TrackEdit.vue
@@ -0,0 +1,34 @@
+<template>
+
+  <section class="ui vertical stripe segment">
+    <div class="ui text container">
+      <h2>
+        <translate v-if="canEdit" key="1" :translate-context="'Content/*/Title'">Edit this track</translate>
+        <translate v-else key="2" :translate-context="'Content/*/Title'">Suggest an edit on this track</translate>
+      </h2>
+      <edit-form :object-type="objectType" :object="object" :can-edit="canEdit"></edit-form>
+      </div>
+    </section>
+</template>
+
+<script>
+import axios from "axios"
+
+import EditForm from '@/components/library/EditForm'
+export default {
+  props: ["objectType", "object", "libraries"],
+  data() {
+    return {
+      id: this.object.id
+    }
+  },
+  components: {
+    EditForm
+  },
+  computed: {
+    canEdit () {
+      return true
+    }
+  }
+}
+</script>
diff --git a/front/src/components/manage/library/EditsCardList.vue b/front/src/components/manage/library/EditsCardList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8e51ff0f0c3bea28520a84d4f100d04551ae190f
--- /dev/null
+++ b/front/src/components/manage/library/EditsCardList.vue
@@ -0,0 +1,231 @@
+<template>
+  <div class="ui text container">
+    <slot></slot>
+    <div class="ui inline form">
+      <div class="fields">
+        <div class="ui field">
+          <label><translate :translate-context="'Content/Search/Input.Label/Noun'">Search</translate></label>
+          <form @submit.prevent="search.query = $refs.search.value">
+            <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
+          </form>
+        </div>
+        <div class="field">
+          <label><translate :translate-context="'Content/Search/Dropdown.Label'">Status</translate></label>
+          <select class="ui dropdown" @change="addSearchToken('is_approved', $event.target.value)" :value="getTokenValue('is_approved', '')">
+            <option value="">
+              <translate :translate-context="'Content/Admin/Dropdown'">All</translate>
+            </option>
+            <option value="null">
+              <translate :translate-context="'Content/Admin/Dropdown'">Pending review</translate>
+            </option>
+            <option value="yes">
+              <translate :translate-context="'Content/Admin/Dropdown'">Approved</translate>
+            </option>
+            <option value="no">
+              <translate :translate-context="'Content/Admin/Dropdown'">Rejected</translate>
+            </option>
+          </select>
+        </div>
+        <div class="field">
+          <label><translate :translate-context="'Content/Search/Dropdown.Label'">Ordering</translate></label>
+          <select class="ui dropdown" v-model="ordering">
+            <option v-for="option in orderingOptions" :value="option[0]">
+              {{ sharedLabels.filters[option[1]] }}
+            </option>
+          </select>
+        </div>
+        <div class="field">
+          <label><translate :translate-context="'Content/Search/Dropdown.Label/Noun'">Order</translate></label>
+          <select class="ui dropdown" v-model="orderingDirection">
+            <option value="+"><translate :translate-context="'Content/Search/Dropdown'">Ascending</translate></option>
+            <option value="-"><translate :translate-context="'Content/Search/Dropdown'">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>
+      <div v-else-if="result && result.count > 0">
+        <edit-card
+          :obj="obj"
+          :current-state="getCurrentState(obj.target)"
+          v-for="obj in result.results"
+          @deleted="handle('delete', obj.uuid, null)"
+          @approved="handle('approved', obj.uuid, $event)"
+          :key="obj.uuid" />
+      </div>
+      <empty-state v-else :refresh="true" @refresh="fetchData()"></empty-state>
+    </div>
+    <div class="ui hidden divider"></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-context="'Content/Library/Paragraph'"
+          :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 Pagination from '@/components/Pagination'
+import OrderingMixin from '@/components/mixins/Ordering'
+import TranslationsMixin from '@/components/mixins/Translations'
+import EditCard from '@/components/library/EditCard'
+import {normalizeQuery, parseTokens} from '@/search'
+import SmartSearchMixin from '@/components/mixins/SmartSearch'
+
+import edits from '@/edits'
+
+
+export default {
+  mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
+  props: {
+    filters: {type: Object, required: false}
+  },
+  components: {
+    Pagination,
+    EditCard
+  },
+  data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
+    return {
+      time,
+      isLoading: false,
+      result: null,
+      page: 1,
+      paginateBy: 25,
+      search: {
+        query: this.defaultQuery,
+        tokens: parseTokens(normalizeQuery(this.defaultQuery))
+      },
+      orderingDirection: defaultOrdering.direction || '+',
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['creation_date', 'creation_date'],
+        ['applied_date', 'applied_date'],
+      ],
+      targets: {
+        track: {}
+      }
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      let params = _.merge({
+        'page': this.page,
+        'page_size': this.paginateBy,
+        'q': this.search.query,
+        'ordering': this.getOrderingAsString()
+      }, this.filters)
+      let self = this
+      self.isLoading = true
+      this.result = null
+      axios.get('mutations/', {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+        self.fetchTargets()
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    fetchTargets () {
+      // we request target data via the API so we can display previous state
+      // additionnal data next to the edit card
+      let self = this
+      let typesAndIds = {
+        track: {
+          url: 'tracks/',
+          ids: [],
+        }
+      }
+      this.result.results.forEach((m) => {
+        if (!m.target || !typesAndIds[m.target.type]) {
+          return
+        }
+        typesAndIds[m.target.type]['ids'].push(m.target.id)
+      })
+      Object.keys(typesAndIds).forEach((k) => {
+        let config = typesAndIds[k]
+        if (config.ids.length === 0) {
+          return
+        }
+        axios.get(config.url, {params: {id: _.uniq(config.ids), hidden: 'null'}}).then((response) => {
+          response.data.results.forEach((e) => {
+            self.$set(self.targets[k], e.id, {
+              payload: e,
+              currentState: edits.getCurrentStateForObj(e, edits.getConfigs.bind(self)()[k])
+            })
+          })
+        }, error => {
+          self.errors = error.backendErrors
+        })
+      })
+    },
+    selectPage: function (page) {
+      this.page = page
+    },
+    handle (type, id, value) {
+      if (type === 'delete') {
+        this.exclude.push(id)
+      }
+
+      this.result.results.forEach((e) => {
+        if (e.uuid === id) {
+          e.is_approved = value
+        }
+      })
+    },
+    getCurrentState (target) {
+      if (!target) {
+        return {}
+      }
+      if (this.targets[target.type] && this.targets[target.type][String(target.id)]) {
+        return this.targets[target.type][String(target.id)].currentState
+      }
+      return {}
+    }
+  },
+  computed: {
+    labels () {
+      return {
+        searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by account, summary, domain…')
+      }
+    },
+  },
+  watch: {
+    search (newValue) {
+      this.page = 1
+      this.fetchData()
+    },
+    page () {
+      this.fetchData()
+    },
+    ordering () {
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/components/manage/library/FilesTable.vue b/front/src/components/manage/library/FilesTable.vue
deleted file mode 100644
index 4716e361acd959732de011960727ef1d780bd01d..0000000000000000000000000000000000000000
--- a/front/src/components/manage/library/FilesTable.vue
+++ /dev/null
@@ -1,216 +0,0 @@
-<template>
-  <div>
-    <div class="ui inline form">
-      <div class="fields">
-        <div class="ui field">
-          <label><translate :translate-context="'Content/Search/Input.Label'">Search</translate></label>
-          <input name="search" type="text" v-model="search" :placeholder="labels.searchPlaceholder" />
-        </div>
-        <div class="field">
-          <label><translate :translate-context="'Content/Search/Dropdown.Label'">Ordering</translate></label>
-          <select class="ui dropdown" v-model="ordering">
-            <option v-for="option in orderingOptions" :value="option[0]">
-              {{ sharedLabels.filters[option[1]] }}
-            </option>
-          </select>
-        </div>
-        <div class="field">
-          <label><translate :translate-context="'Content/Search/Dropdown.Label/Noun'">Order</translate></label>
-          <select class="ui dropdown" v-model="orderingDirection">
-            <option value="+"><translate :translate-context="'Content/Search/Dropdown'">Ascending</translate></option>
-            <option value="-"><translate :translate-context="'Content/Search/Dropdown'">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"
-        :objects-data="result"
-        :actions="actions"
-        :action-url="'manage/library/uploads/action/'"
-        :filters="actionFilters">
-        <template slot="header-cells">
-          <th><translate :translate-context="'*/*/*/Short, Noun'">Title</translate></th>
-          <th><translate :translate-context="'*/*/*/Short, Noun'">Artist</translate></th>
-          <th><translate :translate-context="'*/*/*/Short, Noun'">Album</translate></th>
-          <th><translate :translate-context="'Content/Library/Table.Label/Short, Noun'">Import date</translate></th>
-          <th><translate :translate-context="'Content/Library/Table.Label/Short, Noun'">Type</translate></th>
-          <th><translate :translate-context="'Content/*/*/Short, Noun'">Bitrate</translate></th>
-          <th><translate :translate-context="'Content/*/*/Short, Noun'">Duration</translate></th>
-          <th><translate :translate-context="'Content/*/*/Short, Noun'">Size</translate></th>
-        </template>
-        <template slot="row-cells" slot-scope="scope">
-          <td>
-            <span :title="scope.obj.track.title">{{ scope.obj.track.title|truncate(30) }}</span>
-          </td>
-          <td>
-            <span :title="scope.obj.track.artist.name">{{ scope.obj.track.artist.name|truncate(30) }}</span>
-          </td>
-          <td>
-            <span :title="scope.obj.track.album.title">{{ scope.obj.track.album.title|truncate(20) }}</span>
-          </td>
-          <td>
-            <human-date :date="scope.obj.creation_date"></human-date>
-          </td>
-          <td v-if="scope.obj.mimetype">
-            {{ scope.obj.mimetype }}
-          </td>
-          <td v-else>
-            <translate :translate-context="'*/*/*'">N/A</translate>
-          </td>
-          <td v-if="scope.obj.bitrate">
-            {{ scope.obj.bitrate | humanSize }}/s
-          </td>
-          <td v-else>
-            <translate :translate-context="'*/*/*'">N/A</translate>
-          </td>
-          <td v-if="scope.obj.duration">
-            {{ time.parse(scope.obj.duration) }}
-          </td>
-          <td v-else>
-            <translate :translate-context="'*/*/*'">N/A</translate>
-          </td>
-          <td v-if="scope.obj.size">
-            {{ scope.obj.size | humanSize }}
-          </td>
-          <td v-else>
-            <translate :translate-context="'*/*/*'">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-context="'Content/Library/Paragraph'"
-          :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 Pagination from '@/components/Pagination'
-import ActionTable from '@/components/common/ActionTable'
-import OrderingMixin from '@/components/mixins/Ordering'
-import TranslationsMixin from '@/components/mixins/Translations'
-
-export default {
-  mixins: [OrderingMixin, TranslationsMixin],
-  props: {
-    filters: {type: Object, required: false}
-  },
-  components: {
-    Pagination,
-    ActionTable
-  },
-  data () {
-    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
-    return {
-      time,
-      isLoading: false,
-      result: null,
-      page: 1,
-      paginateBy: 25,
-      search: '',
-      orderingDirection: defaultOrdering.direction || '+',
-      ordering: defaultOrdering.field,
-      orderingOptions: [
-        ['creation_date', 'creation_date'],
-        ['accessed_date', 'accessed_date'],
-        ['modification_date', 'modification_date'],
-        ['size', 'size'],
-        ['bitrate', 'bitrate'],
-        ['duration', 'duration']
-      ]
-
-    }
-  },
-  created () {
-    this.fetchData()
-  },
-  methods: {
-    fetchData () {
-      let params = _.merge({
-        'page': this.page,
-        'page_size': this.paginateBy,
-        'q': this.search,
-        'ordering': this.getOrderingAsString()
-      }, this.filters)
-      let self = this
-      self.isLoading = true
-      self.checked = []
-      axios.get('/manage/library/uploads/', {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.$pgettext('Content/Search/Input.Placeholder', 'Search by title, artist, domain…')
-      }
-    },
-    actionFilters () {
-      var currentFilters = {
-        q: this.search
-      }
-      if (this.filters) {
-        return _.merge(currentFilters, this.filters)
-      } else {
-        return currentFilters
-      }
-    },
-    actions () {
-      let msg = this.$pgettext('Content/Library/Dropdown/Verb', 'Delete')
-      return [
-        {
-          name: 'delete',
-          label: msg,
-          isDangerous: true
-        }
-      ]
-    }
-  },
-  watch: {
-    search (newValue) {
-      this.page = 1
-      this.fetchData()
-    },
-    page () {
-      this.fetchData()
-    },
-    ordering () {
-      this.fetchData()
-    },
-    orderingDirection () {
-      this.fetchData()
-    }
-  }
-}
-</script>
diff --git a/front/src/components/moderation/FilterModal.vue b/front/src/components/moderation/FilterModal.vue
index 39a876546686935b31688db2e9a5083afe1c8c50..5dcd4782504b7513fbced2b7c2daf5769c0db23c 100644
--- a/front/src/components/moderation/FilterModal.vue
+++ b/front/src/components/moderation/FilterModal.vue
@@ -94,7 +94,6 @@ export default {
           date: new Date()
         })
       }, error => {
-        console.log('error', error)
         logger.default.error(`Error while hiding ${self.type} ${self.target.id}`)
         self.errors = error.backendErrors
         self.isLoading = false
diff --git a/front/src/edits.js b/front/src/edits.js
new file mode 100644
index 0000000000000000000000000000000000000000..a57680bac533aab95c3fd49815e12c69ecf13949
--- /dev/null
+++ b/front/src/edits.js
@@ -0,0 +1,81 @@
+export default {
+  getConfigs () {
+    return {
+      track: {
+        fields: [
+          {
+            id: 'title',
+            type: 'text',
+            required: true,
+            label: this.$pgettext('*/*/*/Short, Noun', 'Title'),
+            getValue: (obj) => { return obj.title }
+          },
+          {
+            id: 'license',
+            type: 'text',
+            required: false,
+            label: this.$pgettext('*/*/*/Short, Noun', 'License'),
+            getValue: (obj) => { return obj.license }
+          },
+          {
+            id: 'position',
+            type: 'text',
+            inputType: 'number',
+            required: false,
+            label: this.$pgettext('*/*/*/Short, Noun', 'Position'),
+            getValue: (obj) => { return obj.position }
+          }
+        ]
+      }
+    }
+  },
+
+  getConfig () {
+    return this.configs[this.objectType]
+  },
+
+  getCurrentState () {
+    let self = this
+    let s = {}
+    this.config.fields.forEach(f => {
+      s[f.id] = {value: f.getValue(self.object)}
+    })
+    return s
+  },
+  getCurrentStateForObj (obj, config) {
+    let s = {}
+    config.fields.forEach(f => {
+      s[f.id] = {value: f.getValue(obj)}
+    })
+    return s
+  },
+
+  getCanDelete () {
+    if (this.obj.is_applied || this.obj.is_approved) {
+      return false
+    }
+    if (!this.$store.state.auth.authenticated) {
+      return false
+    }
+    return (
+      this.obj.created_by.full_username === this.$store.state.auth.fullUsername
+      || this.$store.state.auth.availablePermissions['library']
+    )
+  },
+  getCanApprove () {
+    if (this.obj.is_applied) {
+      return false
+    }
+    if (!this.$store.state.auth.authenticated) {
+      return false
+    }
+    return this.$store.state.auth.availablePermissions['library']
+  },
+  getCanEdit () {
+    if (!this.$store.state.auth.authenticated) {
+      return false
+    }
+    return this.$store.state.auth.availablePermissions['library']
+  },
+
+}
diff --git a/front/src/router/index.js b/front/src/router/index.js
index e6e5b2870e282c4a980e8566637a03c90711c2d5..9320b74b81d4b69cb844b09326995b68cf6f401c 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -17,7 +17,10 @@ import LibraryArtist from '@/components/library/Artist'
 import LibraryArtists from '@/components/library/Artists'
 import LibraryAlbums from '@/components/library/Albums'
 import LibraryAlbum from '@/components/library/Album'
-import LibraryTrack from '@/components/library/Track'
+import LibraryTrackDetail from '@/components/library/TrackDetail'
+import LibraryTrackEdit from '@/components/library/TrackEdit'
+import EditDetail from '@/components/library/EditDetail'
+import LibraryTrackDetailBase from '@/components/library/TrackBase'
 import LibraryRadios from '@/components/library/Radios'
 import RadioBuilder from '@/components/library/radios/Builder'
 import RadioDetail from '@/views/radios/Detail'
@@ -26,7 +29,7 @@ import PlaylistList from '@/views/playlists/List'
 import Favorites from '@/components/favorites/List'
 import AdminSettings from '@/views/admin/Settings'
 import AdminLibraryBase from '@/views/admin/library/Base'
-import AdminLibraryFilesList from '@/views/admin/library/FilesList'
+import AdminLibraryEditsList from '@/views/admin/library/EditsList'
 import AdminUsersBase from '@/views/admin/users/Base'
 import AdminUsersList from '@/views/admin/users/UsersList'
 import AdminInvitationsList from '@/views/admin/users/InvitationsList'
@@ -206,9 +209,14 @@ export default new Router({
       component: AdminLibraryBase,
       children: [
         {
-          path: 'files',
-          name: 'manage.library.files',
-          component: AdminLibraryFilesList
+          path: 'edits',
+          name: 'manage.library.edits',
+          component: AdminLibraryEditsList,
+          props: (route) => {
+            return {
+              defaultQuery: route.query.q,
+            }
+          }
         }
       ]
     },
@@ -324,7 +332,29 @@ 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: 'tracks/:id',
+          component: LibraryTrackDetailBase,
+          props: true,
+          children: [
+            {
+              path: '',
+              name: 'library.tracks.detail',
+              component: LibraryTrackDetail
+            },
+            {
+              path: 'edit',
+              name: 'library.tracks.edit',
+              component: LibraryTrackEdit
+            },
+            {
+              path: 'edit/:editId',
+              name: 'library.tracks.edit.detail',
+              component: EditDetail,
+              props: true,
+            }
+          ]
+        },
       ]
     },
     { path: '*', component: PageNotFound }
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index 8893bcb495d1645584ad04da06d72091b6de2e6a..29c4c5d719da539922c36f71a3996b2101ed180b 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -8,6 +8,7 @@ export default {
   state: {
     authenticated: false,
     username: '',
+    fullUsername: '',
     availablePermissions: {
       settings: false,
       library: false,
@@ -27,6 +28,7 @@ export default {
       state.authenticated = false
       state.profile = null
       state.username = ''
+      state.fullUsername = ''
       state.token = ''
       state.tokenData = {}
       state.availablePermissions = {
@@ -43,6 +45,7 @@ export default {
       state.authenticated = value
       if (value === false) {
         state.username = null
+        state.fullUsername = null
         state.token = null
         state.tokenData = null
         state.profile = null
@@ -52,6 +55,9 @@ export default {
     username: (state, value) => {
       state.username = value
     },
+    fullUsername: (state, value) => {
+      state.fullUsername = value
+    },
     avatar: (state, value) => {
       if (state.profile) {
         state.profile.avatar = value
@@ -124,6 +130,7 @@ export default {
             resolve(response.data)
           })
           dispatch('ui/fetchUnreadNotifications', null, { root: true })
+          dispatch('ui/fetchPendingReviewEdits', null, { root: true })
           dispatch('favorites/fetch', null, { root: true })
           dispatch('moderation/fetchContentFilters', null, { root: true })
           dispatch('playlists/fetchOwn', null, { root: true })
@@ -138,6 +145,7 @@ export default {
         commit("authenticated", true)
         commit("profile", data)
         commit("username", data.username)
+        commit("fullUsername", data.full_username)
         Object.keys(data.permissions).forEach(function(key) {
           // this makes it easier to check for permissions in templates
           commit("permission", {
diff --git a/front/src/store/ui.js b/front/src/store/ui.js
index fa4624c700c73b0581d9f2d881e70f81096a2b57..cec9ef9c568b39459495bc73c4c23eb46c449500 100644
--- a/front/src/store/ui.js
+++ b/front/src/store/ui.js
@@ -12,10 +12,13 @@ export default {
     messages: [],
     notifications: {
       inbox: 0,
+      pendingReviewEdits: 0,
     },
     websocketEventsHandlers: {
       'inbox.item_added': {},
       'import.status_updated': {},
+      'mutation.created': {},
+      'mutation.updated': {},
     }
   },
   mutations: {
@@ -44,8 +47,12 @@ export default {
     notifications (state, {type, count}) {
       state.notifications[type] = count
     },
-    incrementNotifications (state, {type, count}) {
-      state.notifications[type] = Math.max(0, state.notifications[type] + count)
+    incrementNotifications (state, {type, count, value}) {
+      if (value != undefined) {
+          state.notifications[type] = Math.max(0, value)
+      } else {
+        state.notifications[type] = Math.max(0, state.notifications[type] + count)
+      }
     }
   },
   actions: {
@@ -54,6 +61,11 @@ export default {
         commit('notifications', {type: 'inbox', count: response.data.count})
       })
     },
+    fetchPendingReviewEdits ({commit, rootState}, payload) {
+      axios.get('mutations/', {params: {is_approved: 'null', page_size: 1}}).then((response) => {
+        commit('notifications', {type: 'pendingReviewEdits', count: response.data.count})
+      })
+    },
     websocketEvent ({state}, event) {
       let handlers = state.websocketEventsHandlers[event.type]
       console.log('Dispatching websocket event', event, handlers)
diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss
index 152881e303447df70732b339a92a4e499b1bb0d3..55e23f320c82cd2205d8abcaef4f900252a172f0 100644
--- a/front/src/style/_main.scss
+++ b/front/src/style/_main.scss
@@ -276,3 +276,12 @@ canvas.color-thief {
 .ui.dropdown .item[disabled] {
   display: none;
 }
+
+span.diff.added {
+  background-color:rgba(0, 255, 0, 0.25);
+}
+
+
+span.diff.removed {
+  background-color: rgba(255, 0, 0, 0.25);
+}
diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue
index 072225a5e13bbb5af4d89eaca9e5593f63f047d4..293e569ef08906dd9c85c2916baa1f6065537b8a 100644
--- a/front/src/views/admin/library/Base.vue
+++ b/front/src/views/admin/library/Base.vue
@@ -3,7 +3,7 @@
     <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
       <router-link
         class="ui item"
-        :to="{name: 'manage.library.files'}"><translate :translate-context="'Menu/Admin/Link'">Files</translate></router-link>
+        :to="{name: 'manage.library.edits'}"><translate :translate-context="'Menu/Admin/Link'">Edits</translate></router-link>
     </nav>
     <router-view :key="$route.fullPath"></router-view>
   </div>
diff --git a/front/src/views/admin/library/EditsList.vue b/front/src/views/admin/library/EditsList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b38732f4ae8e57e9a2196037ae5b19c700f65d07
--- /dev/null
+++ b/front/src/views/admin/library/EditsList.vue
@@ -0,0 +1,33 @@
+<template>
+  <main v-title="labels.title">
+    <section class="ui vertical stripe segment">
+      <edits-card-list :update-url="true" :default-query="defaultQuery">
+        <h2 class="ui header"><translate :translate-context="'Content/Admin/Title/Noun'">Library edits</translate></h2>
+      </edits-card-list>
+    </section>
+  </main>
+</template>
+
+<script>
+import EditsCardList from "@/components/manage/library/EditsCardList"
+
+export default {
+  props: {
+    defaultQuery: {type: String, required: false},
+  },
+  components: {
+    EditsCardList
+  },
+  computed: {
+    labels() {
+      return {
+        title: this.$pgettext('Head/Admin/Title/Noun', 'Edits')
+      }
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/views/admin/library/FilesList.vue b/front/src/views/admin/library/FilesList.vue
deleted file mode 100644
index e23745912635114916e003ff397d0f1dbb954d70..0000000000000000000000000000000000000000
--- a/front/src/views/admin/library/FilesList.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<template>
-  <main v-title="labels.title">
-    <section class="ui vertical stripe segment">
-      <h2 class="ui header"><translate :translate-context="'Content/Admin/Title'">Library files</translate></h2>
-      <div class="ui hidden divider"></div>
-      <library-files-table :show-library="true"></library-files-table>
-    </section>
-  </main>
-</template>
-
-<script>
-import LibraryFilesTable from "@/components/manage/library/FilesTable"
-
-export default {
-  components: {
-    LibraryFilesTable
-  },
-  computed: {
-    labels() {
-      return {
-        title: this.$pgettext('Head/Admin/Title', 'Files')
-      }
-    }
-  }
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-</style>
diff --git a/front/yarn.lock b/front/yarn.lock
index db05be080c2249c397b35053a871a58e6162e283..5d20f4ac1bea08bd5ab6eb8cbd1fe27de24d78b4 100644
--- a/front/yarn.lock
+++ b/front/yarn.lock
@@ -2960,6 +2960,11 @@ diff@3.5.0, diff@^3.5.0:
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
   integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
 
+diff@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff"
+  integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q==
+
 diffie-hellman@^5.0.0:
   version "5.0.3"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"