diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py
index efb2cf4fe4dc4101d9e40ba3a63f9027b58e4bc3..87f7dc8e3a4031b23ee6111c2ebcfb449234857b 100644
--- a/api/funkwhale_api/common/models.py
+++ b/api/funkwhale_api/common/models.py
@@ -4,6 +4,7 @@ from django.contrib.postgres.fields import JSONField
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.conf import settings
+from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models, transaction
 from django.db.models import Lookup
 from django.db.models.fields import Field
@@ -70,8 +71,8 @@ class Mutation(models.Model):
     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)
+    payload = JSONField(encoder=DjangoJSONEncoder)
+    previous_state = JSONField(null=True, default=None, encoder=DjangoJSONEncoder)
 
     target_id = models.IntegerField(null=True)
     target_content_type = models.ForeignKey(
diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py
index 9f14fd110407c45be61bba819fc5eaa681c6adf0..bae2812cea0c678d1024e96a4b50233528cb8e2e 100644
--- a/api/funkwhale_api/federation/routes.py
+++ b/api/funkwhale_api/federation/routes.py
@@ -346,3 +346,37 @@ def outbox_update_track(context):
             to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
         ),
     }
+
+
+@outbox.register({"type": "Update", "object.type": "Album"})
+def outbox_update_album(context):
+    album = context["album"]
+    serializer = serializers.ActivitySerializer(
+        {"type": "Update", "object": serializers.AlbumSerializer(album).data}
+    )
+
+    yield {
+        "type": "Update",
+        "actor": actors.get_service_actor(),
+        "payload": with_recipients(
+            serializer.data,
+            to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
+        ),
+    }
+
+
+@outbox.register({"type": "Update", "object.type": "Artist"})
+def outbox_update_artist(context):
+    artist = context["artist"]
+    serializer = serializers.ActivitySerializer(
+        {"type": "Update", "object": serializers.ArtistSerializer(artist).data}
+    )
+
+    yield {
+        "type": "Update",
+        "actor": actors.get_service_actor(),
+        "payload": with_recipients(
+            serializer.data,
+            to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
+        ),
+    }
diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py
index fdbb7c11cd3c35fb8f2e9c1d5ca146c99d680e92..9fd91fb506d245e0ea50f5ef9cab28c7e7ee0dc1 100644
--- a/api/funkwhale_api/music/mutations.py
+++ b/api/funkwhale_api/music/mutations.py
@@ -28,3 +28,35 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer):
         routes.outbox.dispatch(
             {"type": "Update", "object": {"type": "Track"}}, context={"track": obj}
         )
+
+
+@mutations.registry.connect(
+    "update",
+    models.Artist,
+    perm_checkers={"suggest": can_suggest, "approve": can_approve},
+)
+class ArtistMutationSerializer(mutations.UpdateMutationSerializer):
+    class Meta:
+        model = models.Artist
+        fields = ["name"]
+
+    def post_apply(self, obj, validated_data):
+        routes.outbox.dispatch(
+            {"type": "Update", "object": {"type": "Artist"}}, context={"artist": obj}
+        )
+
+
+@mutations.registry.connect(
+    "update",
+    models.Album,
+    perm_checkers={"suggest": can_suggest, "approve": can_approve},
+)
+class AlbumMutationSerializer(mutations.UpdateMutationSerializer):
+    class Meta:
+        model = models.Album
+        fields = ["title", "release_date"]
+
+    def post_apply(self, obj, validated_data):
+        routes.outbox.dispatch(
+            {"type": "Update", "object": {"type": "Album"}}, context={"album": obj}
+        )
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index eeaa80124d057b382c9bd1acc2d5cc89ea3d3465..b6df2214351499858fec275da4f535d6827f18cd 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -70,6 +70,8 @@ class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelV
     filterset_class = filters.ArtistFilter
     ordering_fields = ("id", "name", "creation_date")
 
+    mutations = common_decorators.mutations_route(types=["update"])
+
     def get_queryset(self):
         queryset = super().get_queryset()
         albums = models.Album.objects.with_tracks_count()
@@ -98,6 +100,8 @@ class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelVi
     ordering_fields = ("creation_date", "release_date", "title")
     filterset_class = filters.AlbumFilter
 
+    mutations = common_decorators.mutations_route(types=["update"])
+
     def get_queryset(self):
         queryset = super().get_queryset()
         tracks = (
diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py
index 10b58082941fce65fd49ff83c1c758f9d3da6887..5dfef61d31c270f1a71643d8d5a167b7689e7478 100644
--- a/api/tests/federation/test_routes.py
+++ b/api/tests/federation/test_routes.py
@@ -448,6 +448,19 @@ def test_inbox_update_artist(factories, mocker):
     update_library_entity.assert_called_once_with(obj, {"name": "New name"})
 
 
+def test_outbox_update_artist(factories):
+    artist = factories["music.Artist"]()
+    activity = list(routes.outbox_update_artist({"artist": artist}))[0]
+    expected = serializers.ActivitySerializer(
+        {"type": "Update", "object": serializers.ArtistSerializer(artist).data}
+    ).data
+
+    expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}]
+
+    assert dict(activity["payload"]) == dict(expected)
+    assert activity["actor"] == actors.get_service_actor()
+
+
 def test_inbox_update_album(factories, mocker):
     update_library_entity = mocker.patch(
         "funkwhale_api.music.tasks.update_library_entity"
@@ -466,6 +479,19 @@ def test_inbox_update_album(factories, mocker):
     update_library_entity.assert_called_once_with(obj, {"title": "New title"})
 
 
+def test_outbox_update_album(factories):
+    album = factories["music.Album"]()
+    activity = list(routes.outbox_update_album({"album": album}))[0]
+    expected = serializers.ActivitySerializer(
+        {"type": "Update", "object": serializers.AlbumSerializer(album).data}
+    ).data
+
+    expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}]
+
+    assert dict(activity["payload"]) == dict(expected)
+    assert activity["actor"] == actors.get_service_actor()
+
+
 def test_inbox_update_track(factories, mocker):
     update_library_entity = mocker.patch(
         "funkwhale_api.music.tasks.update_library_entity"
diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py
index a8a529798b23389e057eb0adfcb19c9326e12bca..be3fb0d76cf831d026e414131b82258e9b1a11db 100644
--- a/api/tests/music/test_mutations.py
+++ b/api/tests/music/test_mutations.py
@@ -1,6 +1,54 @@
+import datetime
+import pytest
+
 from funkwhale_api.music import licenses
 
 
+@pytest.mark.parametrize(
+    "field, old_value, new_value, expected", [("name", "foo", "bar", "bar")]
+)
+def test_artist_mutation(field, old_value, new_value, expected, factories, now, mocker):
+    dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
+    artist = factories["music.Artist"](**{field: old_value})
+    mutation = factories["common.Mutation"](
+        type="update", target=artist, payload={field: new_value}
+    )
+    mutation.apply()
+    artist.refresh_from_db()
+
+    assert getattr(artist, field) == expected
+    dispatch.assert_called_once_with(
+        {"type": "Update", "object": {"type": "Artist"}}, context={"artist": artist}
+    )
+
+
+@pytest.mark.parametrize(
+    "field, old_value, new_value, expected",
+    [
+        ("title", "foo", "bar", "bar"),
+        (
+            "release_date",
+            datetime.date(2016, 1, 1),
+            "2018-02-01",
+            datetime.date(2018, 2, 1),
+        ),
+    ],
+)
+def test_album_mutation(field, old_value, new_value, expected, factories, now, mocker):
+    dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
+    album = factories["music.Album"](**{field: old_value})
+    mutation = factories["common.Mutation"](
+        type="update", target=album, payload={field: new_value}
+    )
+    mutation.apply()
+    album.refresh_from_db()
+
+    assert getattr(album, field) == expected
+    dispatch.assert_called_once_with(
+        {"type": "Update", "object": {"type": "Album"}}, context={"album": album}
+    )
+
+
 def test_track_license_mutation(factories, now):
     track = factories["music.Track"](license=None)
     mutation = factories["common.Mutation"](
diff --git a/front/src/components/library/Album.vue b/front/src/components/library/AlbumBase.vue
similarity index 65%
rename from front/src/components/library/Album.vue
rename to front/src/components/library/AlbumBase.vue
index 1a5f6b50da9e05dc8e1d0b93ba5190909b492fb4..3ff07b10af8af5d8fdd254330f96f6aee500e0ec 100644
--- a/front/src/components/library/Album.vue
+++ b/front/src/components/library/AlbumBase.vue
@@ -1,15 +1,15 @@
 <template>
   <main>
-    <div v-if="isLoading" class="ui vertical segment" v-title="">
+    <div v-if="isLoading" class="ui vertical segment" v-title="labels.title">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
-    <template v-if="album">
-      <section :class="['ui', 'head', {'with-background': album.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title">
+    <template v-if="object">
+      <section :class="['ui', 'head', {'with-background': object.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.title">
         <div class="segment-content">
           <h2 class="ui center aligned icon header">
             <i class="circular inverted sound yellow icon"></i>
             <div class="content">
-              {{ album.title }}
+              {{ object.title }}
               <div v-html="subtitle"></div>
             </div>
           </h2>
@@ -17,7 +17,7 @@
           <div class="header-buttons">
 
             <div class="ui buttons">
-              <play-button class="orange" :tracks="album.tracks">
+              <play-button class="orange" :tracks="object.tracks">
                 <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate>
               </play-button>
             </div>
@@ -28,7 +28,7 @@
               </div>
               <div class="content">
                 <div class="description">
-                  <embed-wizard type="album" :id="album.id" />
+                  <embed-wizard type="album" :id="object.id" />
 
                 </div>
               </div>
@@ -61,15 +61,22 @@
                     <i class="external icon"></i>
                     <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
                   </a>
+                  <router-link
+                    v-if="object.is_local"
+                    :to="{name: 'library.albums.edit', params: {id: object.id }}"
+                    class="basic item">
+                    <i class="edit icon"></i>
+                    <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
+                  </router-link>
                   <div class="divider"></div>
-                  <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: album.id}}">
+                  <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}">
                     <i class="wrench icon"></i>
                     <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
                   </router-link>
                   <a
                     v-if="$store.state.auth.profile.is_superuser"
                     class="basic item"
-                    :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${album.id}`)"
+                    :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
                     target="_blank" rel="noopener noreferrer">
                     <i class="wrench icon"></i>
                     <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
@@ -80,36 +87,7 @@
           </div>
         </div>
       </section>
-      <template v-if="discs && discs.length > 1">
-        <section v-for="(tracks, disc_number) in discs" class="ui vertical stripe segment">
-          <translate
-            tag="h2"
-            class="left floated"
-            :translate-params="{number: disc_number + 1}"
-            translate-context="Content/Album/"
-          >Volume %{ number }</translate>
-          <play-button class="right floated orange" :tracks="tracks">
-            <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate>
-          </play-button>
-          <track-table :artist="album.artist" :display-position="true" :tracks="tracks"></track-table>
-        </section>
-      </template>
-      <template v-else>
-        <section class="ui vertical stripe segment">
-          <h2>
-            <translate translate-context="*/*/*/Noun">Tracks</translate>
-          </h2>
-          <track-table v-if="album" :artist="album.artist" :display-position="true" :tracks="album.tracks"></track-table>
-        </section>
-      </template>
-      <section class="ui vertical stripe segment">
-        <h2>
-          <translate translate-context="Content/*/Title/Noun">User libraries</translate>
-        </h2>
-        <library-widget @loaded="libraries = $event" :url="'albums/' + id + '/libraries/'">
-          <translate slot="subtitle" translate-context="Content/Album/Paragraph">This album is present in the following libraries:</translate>
-        </library-widget>
-      </section>
+      <router-view v-if="object" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view>
     </template>
   </main>
 </template>
@@ -119,13 +97,12 @@ import axios from "axios"
 import logger from "@/logging"
 import backend from "@/audio/backend"
 import PlayButton from "@/components/audio/PlayButton"
-import TrackTable from "@/components/audio/track/Table"
-import LibraryWidget from "@/components/federation/LibraryWidget"
 import EmbedWizard from "@/components/audio/EmbedWizard"
 import Modal from '@/components/semantic/Modal'
 
 const FETCH_URL = "albums/"
 
+
 function groupByDisc(acc, track) {
   var dn = track.disc_number - 1
   if (dn < 0) dn = 0
@@ -141,15 +118,13 @@ export default {
   props: ["id"],
   components: {
     PlayButton,
-    TrackTable,
-    LibraryWidget,
     EmbedWizard,
     Modal
   },
   data() {
     return {
       isLoading: true,
-      album: null,
+      object: null,
       discs: [],
       libraries: [],
       showEmbedModal: false
@@ -165,8 +140,8 @@ export default {
       let url = FETCH_URL + this.id + "/"
       logger.default.debug('Fetching album "' + this.id + '"')
       axios.get(url).then(response => {
-        self.album = backend.Album.clean(response.data)
-        self.discs = self.album.tracks.reduce(groupByDisc, [])
+        self.object = backend.Album.clean(response.data)
+        self.discs = self.object.tracks.reduce(groupByDisc, [])
         self.isLoading = false
       })
     }
@@ -185,28 +160,28 @@ export default {
     wikipediaUrl() {
       return (
         "https://en.wikipedia.org/w/index.php?search=" +
-        encodeURI(this.album.title + " " + this.album.artist.name)
+        encodeURI(this.object.title + " " + this.object.artist.name)
       )
     },
     musicbrainzUrl() {
-      if (this.album.mbid) {
-        return "https://musicbrainz.org/release/" + this.album.mbid
+      if (this.object.mbid) {
+        return "https://musicbrainz.org/release/" + this.object.mbid
       }
     },
     headerStyle() {
-      if (!this.album.cover.original) {
+      if (!this.object.cover.original) {
         return ""
       }
       return (
         "background-image: url(" +
-        this.$store.getters["instance/absoluteUrl"](this.album.cover.original) +
+        this.$store.getters["instance/absoluteUrl"](this.object.cover.original) +
         ")"
       )
     },
     subtitle () {
-      let route = this.$router.resolve({name: 'library.artists.detail', params: {id: this.album.artist.id }})
-      let msg = this.$npgettext('Content/Album/Header.Title', 'Album containing %{ count } track, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', 'Album containing %{ count } tracks, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', this.album.tracks.length)
-      return this.$gettextInterpolate(msg, {count: this.album.tracks.length, artist: this.album.artist.name, artistUrl: route.location.path})
+      let route = this.$router.resolve({name: 'library.artists.detail', params: {id: this.object.artist.id }})
+      let msg = this.$npgettext('Content/Album/Header.Title', 'Album containing %{ count } track, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', 'Album containing %{ count } tracks, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', this.object.tracks.length)
+      return this.$gettextInterpolate(msg, {count: this.object.tracks.length, artist: this.object.artist.name, artistUrl: route.location.path})
     }
   },
   watch: {
@@ -216,7 +191,3 @@ export default {
   }
 }
 </script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-</style>
diff --git a/front/src/components/library/AlbumDetail.vue b/front/src/components/library/AlbumDetail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d695b6e596b3d46de95774d8191f18e7309bb277
--- /dev/null
+++ b/front/src/components/library/AlbumDetail.vue
@@ -0,0 +1,62 @@
+<template>
+  <div v-if="object">
+    <template v-if="discs && discs.length > 1">
+      <section v-for="(tracks, disc_number) in discs" class="ui vertical stripe segment">
+        <translate
+          tag="h2"
+          class="left floated"
+          :translate-params="{number: disc_number + 1}"
+          translate-context="Content/Album/"
+        >Volume %{ number }</translate>
+        <play-button class="right floated orange" :tracks="tracks">
+          <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate>
+        </play-button>
+        <track-table :artist="object.artist" :display-position="true" :tracks="tracks"></track-table>
+      </section>
+    </template>
+    <template v-else>
+      <section class="ui vertical stripe segment">
+        <h2>
+          <translate translate-context="*/*/*/Noun">Tracks</translate>
+        </h2>
+        <track-table v-if="object" :artist="object.artist" :display-position="true" :tracks="object.tracks"></track-table>
+      </section>
+    </template>
+    <section class="ui vertical stripe segment">
+      <h2>
+        <translate translate-context="Content/*/Title/Noun">User libraries</translate>
+      </h2>
+      <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'albums/' + object.id + '/libraries/'">
+        <translate slot="subtitle" translate-context="Content/Album/Paragraph">This album 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"
+import TrackTable from "@/components/audio/track/Table"
+
+export default {
+  props: ["object", "libraries", "discs"],
+  components: {
+    LibraryWidget,
+    TrackTable
+  },
+  data() {
+    return {
+      time,
+      id: this.object.id,
+    }
+  },
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped lang="scss">
+</style>
diff --git a/front/src/components/library/AlbumEdit.vue b/front/src/components/library/AlbumEdit.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b7c24737c1dd5e07b737dd1a6e088f1edc179920
--- /dev/null
+++ b/front/src/components/library/AlbumEdit.vue
@@ -0,0 +1,41 @@
+<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 album</translate>
+        <translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this album</translate>
+      </h2>
+      <div class="ui message" v-if="!object.is_local">
+        <translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate>
+      </div>
+      <edit-form
+        v-else
+        :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/library/Artist.vue b/front/src/components/library/ArtistBase.vue
similarity index 67%
rename from front/src/components/library/Artist.vue
rename to front/src/components/library/ArtistBase.vue
index 4d6e10e2d755a924b294a85f1bdb49c340d17e46..d4efcb82ec101214830506fbe1f23057dbd32dcd 100644
--- a/front/src/components/library/Artist.vue
+++ b/front/src/components/library/ArtistBase.vue
@@ -3,13 +3,13 @@
     <div v-if="isLoading" class="ui vertical segment">
       <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
     </div>
-    <template v-if="artist">
-      <section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="artist.name">
+    <template v-if="object">
+      <section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.name">
         <div class="segment-content">
           <h2 class="ui center aligned icon header">
             <i class="circular inverted users violet icon"></i>
             <div class="content">
-              {{ artist.name }}
+              {{ object.name }}
               <div class="sub header" v-if="albums">
                 <translate translate-context="Content/Artist/Paragraph"
                   tag="div"
@@ -24,11 +24,11 @@
           <div class="ui hidden divider"></div>
           <div class="header-buttons">
             <div class="ui buttons">
-              <radio-button type="artist" :object-id="artist.id"></radio-button>
+              <radio-button type="artist" :object-id="object.id"></radio-button>
 
             </div>
             <div class="ui buttons">
-              <play-button :is-playable="isPlayable" class="orange" :artist="artist">
+              <play-button :is-playable="isPlayable" class="orange" :artist="object">
                 <translate translate-context="Content/Artist/Button.Label/Verb">Play all albums</translate>
               </play-button>
             </div>
@@ -39,7 +39,7 @@
               </div>
               <div class="content">
                 <div class="description">
-                  <embed-wizard type="artist" :id="artist.id" />
+                  <embed-wizard type="artist" :id="object.id" />
 
                 </div>
               </div>
@@ -72,15 +72,22 @@
                     <i class="external icon"></i>
                     <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
                   </a>
+                  <router-link
+                    v-if="object.is_local"
+                    :to="{name: 'library.artists.edit', params: {id: object.id }}"
+                    class="basic item">
+                    <i class="edit icon"></i>
+                    <translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
+                  </router-link>
                   <div class="divider"></div>
-                  <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: artist.id}}">
+                  <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: object.id}}">
                     <i class="wrench icon"></i>
                     <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
                   </router-link>
                   <a
                     v-if="$store.state.auth.profile.is_superuser"
                     class="basic item"
-                    :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${artist.id}`)"
+                    :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
                     target="_blank" rel="noopener noreferrer">
                     <i class="wrench icon"></i>
                     <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
@@ -91,84 +98,40 @@
           </div>
         </div>
       </section>
-      <div class="ui small text container" v-if="contentFilter">
-        <div class="ui hidden divider"></div>
-        <div class="ui message">
-          <p>
-            <translate translate-context="Content/Artist/Paragraph">You are currently hiding content related to this artist.</translate>
-          </p>
-          <router-link class="right floated" :to="{name: 'settings'}">
-            <translate translate-context="Content/Moderation/Link">Review my filters</translate>
-          </router-link>
-          <button @click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)" class="ui basic tiny button">
-            <translate translate-context="Content/Moderation/Button.Label">Remove filter</translate>
-          </button>
-        </div>
-      </div>
-      <section v-if="isLoadingAlbums" class="ui vertical stripe segment">
-        <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
-      </section>
-      <section v-else-if="albums && albums.length > 0" class="ui vertical stripe segment">
-        <h2>
-          <translate translate-context="Content/Artist/Title">Albums by this artist</translate>
-        </h2>
-        <div class="ui cards" >
-          <album-card :mode="'rich'" :album="album" :key="album.id" v-for="album in albums"></album-card>
-        </div>
-      </section>
-      <section v-if="tracks.length > 0" class="ui vertical stripe segment">
-        <h2>
-          <translate translate-context="Content/Artist/Title">Tracks by this artist</translate>
-        </h2>
-        <track-table :display-position="true" :tracks="tracks"></track-table>
-      </section>
-      <section class="ui vertical stripe segment">
-        <h2>
-          <translate translate-context="Content/*/Title/Noun">User libraries</translate>
-        </h2>
-        <library-widget @loaded="libraries = $event" :url="'artists/' + id + '/libraries/'">
-          <translate translate-context="Content/Artist/Paragraph" slot="subtitle">This artist is present in the following libraries:</translate>
-        </library-widget>
-      </section>
+      <router-view v-if="object" :tracks="tracks" :albums="albums" :is-loading-albums="isLoadingAlbums" @libraries-loaded="libraries = $event" :object="object" object-type="artist" :key="$route.fullPath"></router-view>
     </template>
   </main>
 </template>
 
 <script>
-import _ from "@/lodash"
 import axios from "axios"
 import logger from "@/logging"
 import backend from "@/audio/backend"
-import AlbumCard from "@/components/audio/album/Card"
-import RadioButton from "@/components/radios/Button"
 import PlayButton from "@/components/audio/PlayButton"
-import TrackTable from "@/components/audio/track/Table"
-import LibraryWidget from "@/components/federation/LibraryWidget"
 import EmbedWizard from "@/components/audio/EmbedWizard"
 import Modal from '@/components/semantic/Modal'
+import RadioButton from "@/components/radios/Button"
+
+const FETCH_URL = "albums/"
+
 
 export default {
   props: ["id"],
   components: {
-    AlbumCard,
-    RadioButton,
     PlayButton,
-    TrackTable,
-    LibraryWidget,
     EmbedWizard,
-    Modal
+    Modal,
+    RadioButton
   },
   data() {
     return {
       isLoading: true,
       isLoadingAlbums: true,
-      artist: null,
+      object: null,
       albums: null,
-      totalTracks: 0,
-      totalAlbums: 0,
-      tracks: [],
       libraries: [],
-      showEmbedModal: false
+      showEmbedModal: false,
+      tracks: [],
     }
   },
   created() {
@@ -184,7 +147,7 @@ export default {
         self.totalTracks = response.data.count
       })
       axios.get("artists/" + this.id + "/").then(response => {
-        self.artist = response.data
+        self.object = response.data
         self.isLoading = false
         self.isLoadingAlbums = true
         axios
@@ -204,40 +167,31 @@ export default {
     }
   },
   computed: {
-    labels() {
-      return {
-        title: this.$pgettext('*/*/*/Noun', "Artist")
-      }
-    },
     isPlayable() {
       return (
-        this.artist.albums.filter(a => {
+        this.object.albums.filter(a => {
           return a.is_playable
         }).length > 0
       )
     },
+    labels() {
+      return {
+        title: this.$pgettext('*/*/*', 'Album')
+      }
+    },
     wikipediaUrl() {
       return (
         "https://en.wikipedia.org/w/index.php?search=" +
-        encodeURI(this.artist.name)
+        encodeURI(this.object.name)
       )
     },
     musicbrainzUrl() {
-      if (this.artist.mbid) {
-        return "https://musicbrainz.org/artist/" + this.artist.mbid
+      if (this.object.mbid) {
+        return "https://musicbrainz.org/artist/" + this.object.mbid
       }
     },
-    allTracks() {
-      let tracks = []
-      this.albums.forEach(album => {
-        album.tracks.forEach(track => {
-          tracks.push(track)
-        })
-      })
-      return tracks
-    },
     cover() {
-      return this.artist.albums
+      return this.object.albums
         .filter(album => {
           return album.cover
         })
@@ -264,7 +218,7 @@ export default {
     contentFilter () {
       let self = this
       return this.$store.getters['moderation/artistFilters']().filter((e) => {
-        return e.target.id === this.artist.id
+        return e.target.id === this.object.id
       })[0]
     }
   },
@@ -275,7 +229,3 @@ export default {
   }
 }
 </script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-</style>
diff --git a/front/src/components/library/ArtistDetail.vue b/front/src/components/library/ArtistDetail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..50e1856e91cab720459674997c1b33581a316949
--- /dev/null
+++ b/front/src/components/library/ArtistDetail.vue
@@ -0,0 +1,79 @@
+<template>
+  <div v-if="object">
+    <div class="ui small text container" v-if="contentFilter">
+      <div class="ui hidden divider"></div>
+      <div class="ui message">
+        <p>
+          <translate translate-context="Content/Artist/Paragraph">You are currently hiding content related to this artist.</translate>
+        </p>
+        <router-link class="right floated" :to="{name: 'settings'}">
+          <translate translate-context="Content/Moderation/Link">Review my filters</translate>
+        </router-link>
+        <button @click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)" class="ui basic tiny button">
+          <translate translate-context="Content/Moderation/Button.Label">Remove filter</translate>
+        </button>
+      </div>
+    </div>
+    <section v-if="isLoadingAlbums" class="ui vertical stripe segment">
+      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+    </section>
+    <section v-else-if="albums && albums.length > 0" class="ui vertical stripe segment">
+      <h2>
+        <translate translate-context="Content/Artist/Title">Albums by this artist</translate>
+      </h2>
+      <div class="ui cards" >
+        <album-card :mode="'rich'" :album="album" :key="album.id" v-for="album in albums"></album-card>
+      </div>
+    </section>
+    <section v-if="tracks.length > 0" class="ui vertical stripe segment">
+      <h2>
+        <translate translate-context="Content/Artist/Title">Tracks by this artist</translate>
+      </h2>
+      <track-table :display-position="true" :tracks="tracks"></track-table>
+    </section>
+    <section class="ui vertical stripe segment">
+      <h2>
+        <translate translate-context="Content/*/Title/Noun">User libraries</translate>
+      </h2>
+      <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'artists/' + object.id + '/libraries/'">
+        <translate translate-context="Content/Artist/Paragraph" slot="subtitle">This artist is present in the following libraries:</translate>
+      </library-widget>
+    </section>
+  </div>
+</template>
+
+<script>
+import _ from "@/lodash"
+import axios from "axios"
+import logger from "@/logging"
+import backend from "@/audio/backend"
+import AlbumCard from "@/components/audio/album/Card"
+import TrackTable from "@/components/audio/track/Table"
+import LibraryWidget from "@/components/federation/LibraryWidget"
+
+export default {
+  props: ["object", "tracks", "albums", "isLoadingAlbums"],
+  components: {
+    AlbumCard,
+    TrackTable,
+    LibraryWidget,
+  },
+  computed: {
+    contentFilter () {
+      let self = this
+      return this.$store.getters['moderation/artistFilters']().filter((e) => {
+        return e.target.id === this.object.id
+      })[0]
+    }
+  },
+  watch: {
+    id() {
+      this.fetchData()
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
diff --git a/front/src/components/library/ArtistEdit.vue b/front/src/components/library/ArtistEdit.vue
new file mode 100644
index 0000000000000000000000000000000000000000..80a9ae0c3ce130720f62b389b6dac3a2c3c1baf9
--- /dev/null
+++ b/front/src/components/library/ArtistEdit.vue
@@ -0,0 +1,41 @@
+<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 artist</translate>
+        <translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this artist</translate>
+      </h2>
+      <div class="ui message" v-if="!object.is_local">
+        <translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate>
+      </div>
+      <edit-form
+        v-else
+        :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/library/EditForm.vue b/front/src/components/library/EditForm.vue
index a2df96c0018dcf0d2f9bf5dc461be7cf872632c1..617917c6812f03924934c48ec0e7d92079f95a72 100644
--- a/front/src/components/library/EditForm.vue
+++ b/front/src/components/library/EditForm.vue
@@ -149,6 +149,12 @@ export default {
       if (this.objectType === 'track') {
         return `tracks/${this.object.id}/mutations/`
       }
+      if (this.objectType === 'album') {
+        return `albums/${this.object.id}/mutations/`
+      }
+      if (this.objectType === 'artist') {
+        return `artists/${this.object.id}/mutations/`
+      }
     },
     mutationPayload () {
       let self = this
diff --git a/front/src/edits.js b/front/src/edits.js
index c72cb4b09822bb716a9358e49b046f26141e901b..a53ab2fcc286d31815b81b99cb4a161a53416c4a 100644
--- a/front/src/edits.js
+++ b/front/src/edits.js
@@ -1,13 +1,42 @@
 export default {
   getConfigs () {
     return {
+      artist: {
+        fields: [
+          {
+            id: 'name',
+            type: 'text',
+            required: true,
+            label: this.$pgettext('*/*/*/Noun', 'Name'),
+            getValue: (obj) => { return obj.name }
+          },
+        ]
+      },
+      album: {
+        fields: [
+          {
+            id: 'title',
+            type: 'text',
+            required: true,
+            label: this.$pgettext('*/*/*/Noun', 'Title'),
+            getValue: (obj) => { return obj.title }
+          },
+          {
+            id: 'release_date',
+            type: 'text',
+            required: false,
+            label: this.$pgettext('Content/*/*/Noun', 'Release date'),
+            getValue: (obj) => { return obj.release_date }
+          },
+        ]
+      },
       track: {
         fields: [
           {
             id: 'title',
             type: 'text',
             required: true,
-            label: this.$pgettext('Content/Track/*/Noun', 'Title'),
+            label: this.$pgettext('*/*/*/Noun', 'Title'),
             getValue: (obj) => { return obj.title }
           },
           {
diff --git a/front/src/router/index.js b/front/src/router/index.js
index bee09f20281da8c94c6543c07ba63820bfa4f67b..f9332f5f556683067ae0fe03b187a2826d6e0e41 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -16,10 +16,14 @@ import PasswordResetConfirm from '@/views/auth/PasswordResetConfirm'
 import EmailConfirm from '@/views/auth/EmailConfirm'
 import Library from '@/components/library/Library'
 import LibraryHome from '@/components/library/Home'
-import LibraryArtist from '@/components/library/Artist'
 import LibraryArtists from '@/components/library/Artists'
+import LibraryArtistDetail from '@/components/library/ArtistDetail'
+import LibraryArtistEdit from '@/components/library/ArtistEdit'
+import LibraryArtistDetailBase from '@/components/library/ArtistBase'
 import LibraryAlbums from '@/components/library/Albums'
-import LibraryAlbum from '@/components/library/Album'
+import LibraryAlbumDetail from '@/components/library/AlbumDetail'
+import LibraryAlbumEdit from '@/components/library/AlbumEdit'
+import LibraryAlbumDetailBase from '@/components/library/AlbumBase'
 import LibraryTrackDetail from '@/components/library/TrackDetail'
 import LibraryTrackEdit from '@/components/library/TrackEdit'
 import EditDetail from '@/components/library/EditDetail'
@@ -411,8 +415,52 @@ export default new Router({
             id: route.params.id,
             defaultEdit: route.query.mode === 'edit' })
         },
-        { path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
-        { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
+        {
+          path: 'artists/:id',
+          component: LibraryArtistDetailBase,
+          props: true,
+          children: [
+            {
+              path: '',
+              name: 'library.artists.detail',
+              component: LibraryArtistDetail
+            },
+            {
+              path: 'edit',
+              name: 'library.artists.edit',
+              component: LibraryArtistEdit
+            },
+            {
+              path: 'edit/:editId',
+              name: 'library.artists.edit.detail',
+              component: EditDetail,
+              props: true,
+            }
+          ]
+        },
+        {
+          path: 'albums/:id',
+          component: LibraryAlbumDetailBase,
+          props: true,
+          children: [
+            {
+              path: '',
+              name: 'library.albums.detail',
+              component: LibraryAlbumDetail
+            },
+            {
+              path: 'edit',
+              name: 'library.albums.edit',
+              component: LibraryAlbumEdit
+            },
+            {
+              path: 'edit/:editId',
+              name: 'library.albums.edit.detail',
+              component: EditDetail,
+              props: true,
+            }
+          ]
+        },
         {
           path: 'tracks/:id',
           component: LibraryTrackDetailBase,