From 11a533fa923634df50e11e1227dd302e798d9654 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Wed, 27 Nov 2019 12:26:12 +0100
Subject: [PATCH] Resolve "Adding cover art to my albums"

---
 api/funkwhale_api/common/factories.py         |  8 --
 .../migrations/0005_auto_20191125_1421.py     | 37 +++++++
 api/funkwhale_api/common/models.py            | 46 ++++++++-
 api/funkwhale_api/common/serializers.py       |  7 +-
 api/funkwhale_api/common/views.py             |  4 +-
 api/funkwhale_api/federation/serializers.py   | 30 ++++++
 api/funkwhale_api/music/mutations.py          | 41 +++++++-
 api/tests/common/test_tasks.py                | 15 ++-
 api/tests/conftest.py                         |  5 +
 api/tests/federation/test_routes.py           |  2 +-
 api/tests/federation/test_serializers.py      | 41 ++++++++
 api/tests/music/test_mutations.py             | 19 ++++
 changes/changelog.d/588.enhancement           |  1 +
 .../src/components/common/AttachmentInput.vue | 99 +++++++++++++++++++
 front/src/components/library/EditCard.vue     | 32 ++++--
 front/src/components/library/EditForm.vue     | 15 ++-
 front/src/edits.js                            | 13 +++
 17 files changed, 388 insertions(+), 27 deletions(-)
 create mode 100644 api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py
 create mode 100644 changes/changelog.d/588.enhancement
 create mode 100644 front/src/components/common/AttachmentInput.vue

diff --git a/api/funkwhale_api/common/factories.py b/api/funkwhale_api/common/factories.py
index 85a441e852..d6a0636039 100644
--- a/api/funkwhale_api/common/factories.py
+++ b/api/funkwhale_api/common/factories.py
@@ -16,14 +16,6 @@ class MutationFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
     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()
-
 
 @registry.register
 class AttachmentFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
diff --git a/api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py b/api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py
new file mode 100644
index 0000000000..b0984c14e6
--- /dev/null
+++ b/api/funkwhale_api/common/migrations/0005_auto_20191125_1421.py
@@ -0,0 +1,37 @@
+# Generated by Django 2.2.7 on 2019-11-25 14:21
+
+import django.contrib.postgres.fields.jsonb
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('common', '0004_auto_20191111_1338'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='mutation',
+            name='payload',
+            field=django.contrib.postgres.fields.jsonb.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder),
+        ),
+        migrations.AlterField(
+            model_name='mutation',
+            name='previous_state',
+            field=django.contrib.postgres.fields.jsonb.JSONField(default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True),
+        ),
+        migrations.CreateModel(
+            name='MutationAttachment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('attachment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mutation_attachment', to='common.Attachment')),
+                ('mutation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mutation_attachment', to='common.Mutation')),
+            ],
+            options={
+                'unique_together': {('attachment', 'mutation')},
+            },
+        ),
+    ]
diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py
index f764bf251e..4e4fc14dd5 100644
--- a/api/funkwhale_api/common/models.py
+++ b/api/funkwhale_api/common/models.py
@@ -167,7 +167,7 @@ def get_file_path(instance, filename):
 
 class AttachmentQuerySet(models.QuerySet):
     def attached(self, include=True):
-        related_fields = ["covered_album"]
+        related_fields = ["covered_album", "mutation_attachment"]
         query = None
         for field in related_fields:
             field_query = ~models.Q(**{field: None})
@@ -178,6 +178,12 @@ class AttachmentQuerySet(models.QuerySet):
 
         return self.filter(query)
 
+    def local(self, include=True):
+        if include:
+            return self.filter(actor__domain_id=settings.FEDERATION_HOSTNAME)
+        else:
+            return self.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
+
 
 class Attachment(models.Model):
     # Remote URL where the attachment can be fetched
@@ -248,6 +254,25 @@ class Attachment(models.Model):
         return federation_utils.full_url(proxy_url + "?next=medium_square_crop")
 
 
+class MutationAttachment(models.Model):
+    """
+    When using attachments in mutations, we need to keep a reference to
+    the attachment to ensure it is not pruned by common/tasks.py.
+
+    This is what this model does.
+    """
+
+    attachment = models.OneToOneField(
+        Attachment, related_name="mutation_attachment", on_delete=models.CASCADE
+    )
+    mutation = models.OneToOneField(
+        Mutation, related_name="mutation_attachment", on_delete=models.CASCADE
+    )
+
+    class Meta:
+        unique_together = ("attachment", "mutation")
+
+
 @receiver(models.signals.post_save, sender=Attachment)
 def warm_attachment_thumbnails(sender, instance, **kwargs):
     if not instance.file or not settings.CREATE_IMAGE_THUMBNAILS:
@@ -258,3 +283,22 @@ def warm_attachment_thumbnails(sender, instance, **kwargs):
         image_attr="file",
     )
     num_created, failed_to_create = warmer.warm()
+
+
+@receiver(models.signals.post_save, sender=Mutation)
+def trigger_mutation_post_init(sender, instance, created, **kwargs):
+    if not created:
+        return
+
+    from . import mutations
+
+    try:
+        conf = mutations.registry.get_conf(instance.type, instance.target)
+    except mutations.ConfNotFound:
+        return
+    serializer = conf["serializer_class"]()
+    try:
+        handler = serializer.mutation_post_init
+    except AttributeError:
+        return
+    handler(instance)
diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py
index 3f128c18d2..fa889f9e88 100644
--- a/api/funkwhale_api/common/serializers.py
+++ b/api/funkwhale_api/common/serializers.py
@@ -23,9 +23,10 @@ class RelatedField(serializers.RelatedField):
         self.related_field_name = related_field_name
         self.serializer = serializer
         self.filters = kwargs.pop("filters", None)
-        kwargs["queryset"] = kwargs.pop(
-            "queryset", self.serializer.Meta.model.objects.all()
-        )
+        try:
+            kwargs["queryset"] = kwargs.pop("queryset")
+        except KeyError:
+            kwargs["queryset"] = self.serializer.Meta.model.objects.all()
         super().__init__(**kwargs)
 
     def get_filters(self, data):
diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py
index 1109589d0e..b6f8d5a0af 100644
--- a/api/funkwhale_api/common/views.py
+++ b/api/funkwhale_api/common/views.py
@@ -157,7 +157,9 @@ class AttachmentViewSet(
     required_scope = "libraries"
     anonymous_policy = "setting"
 
-    @action(detail=True, methods=["get"])
+    @action(
+        detail=True, methods=["get"], permission_classes=[], authentication_classes=[]
+    )
     @transaction.atomic
     def proxy(self, request, *args, **kwargs):
         instance = self.get_object()
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 5a62f4ce3c..977ab0bb1d 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -5,9 +5,12 @@ import uuid
 
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.paginator import Paginator
+from django.db import transaction
+
 from rest_framework import serializers
 
 from funkwhale_api.common import utils as funkwhale_utils
+from funkwhale_api.common import models as common_models
 from funkwhale_api.music import licenses
 from funkwhale_api.music import models as music_models
 from funkwhale_api.music import tasks as music_tasks
@@ -808,6 +811,7 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
         child=TagSerializer(), min_length=0, required=False, allow_null=True
     )
 
+    @transaction.atomic
     def update(self, instance, validated_data):
         attributed_to_fid = validated_data.get("attributedTo")
         if attributed_to_fid:
@@ -815,6 +819,8 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
         updated_fields = funkwhale_utils.get_updated_fields(
             self.updateable_fields, validated_data, instance
         )
+        updated_fields = self.validate_updated_data(instance, updated_fields)
+
         if updated_fields:
             music_tasks.update_library_entity(instance, updated_fields)
 
@@ -828,6 +834,9 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
             for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name)
         ]
 
+    def validate_updated_data(self, instance, validated_data):
+        return validated_data
+
 
 class ArtistSerializer(MusicEntitySerializer):
     updateable_fields = [
@@ -869,6 +878,7 @@ class AlbumSerializer(MusicEntitySerializer):
         ("musicbrainzId", "mbid"),
         ("attributedTo", "attributed_to"),
         ("released", "release_date"),
+        ("cover", "attachment_cover"),
     ]
 
     class Meta:
@@ -912,6 +922,26 @@ class AlbumSerializer(MusicEntitySerializer):
             d["@context"] = jsonld.get_default_context()
         return d
 
+    def validate_updated_data(self, instance, validated_data):
+        try:
+            attachment_cover = validated_data.pop("attachment_cover")
+        except KeyError:
+            return validated_data
+
+        if (
+            instance.attachment_cover
+            and instance.attachment_cover.url == attachment_cover["href"]
+        ):
+            # we already have the proper attachment
+            return validated_data
+        # create the attachment by hand so it can be attached as the album cover
+        validated_data["attachment_cover"] = common_models.Attachment.objects.create(
+            mimetype=attachment_cover["mediaType"],
+            url=attachment_cover["href"],
+            actor=instance.attributed_to,
+        )
+        return validated_data
+
 
 class TrackSerializer(MusicEntitySerializer):
     position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py
index b149f19635..aad3b0e965 100644
--- a/api/funkwhale_api/music/mutations.py
+++ b/api/funkwhale_api/music/mutations.py
@@ -1,4 +1,6 @@
+from funkwhale_api.common import models as common_models
 from funkwhale_api.common import mutations
+from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.federation import routes
 from funkwhale_api.tags import models as tags_models
 from funkwhale_api.tags import serializers as tags_serializers
@@ -74,11 +76,48 @@ class ArtistMutationSerializer(TagMutation):
     perm_checkers={"suggest": can_suggest, "approve": can_approve},
 )
 class AlbumMutationSerializer(TagMutation):
+    cover = common_serializers.RelatedField(
+        "uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None
+    )
+
+    serialized_relations = {"cover": "uuid"}
+    previous_state_handlers = dict(
+        list(TagMutation.previous_state_handlers.items())
+        + [
+            (
+                "cover",
+                lambda obj: str(obj.attachment_cover.uuid)
+                if obj.attachment_cover
+                else None,
+            ),
+        ]
+    )
+
     class Meta:
         model = models.Album
-        fields = ["title", "release_date", "tags"]
+        fields = ["title", "release_date", "tags", "cover"]
 
     def post_apply(self, obj, validated_data):
         routes.outbox.dispatch(
             {"type": "Update", "object": {"type": "Album"}}, context={"album": obj}
         )
+
+    def update(self, instance, validated_data):
+        if "cover" in validated_data:
+            validated_data["attachment_cover"] = validated_data.pop("cover")
+        return super().update(instance, validated_data)
+
+    def mutation_post_init(self, mutation):
+        # link cover_attachment (if any) to mutation
+        if "cover" not in mutation.payload:
+            return
+        try:
+            attachment = common_models.Attachment.objects.get(
+                uuid=mutation.payload["cover"]
+            )
+        except common_models.Attachment.DoesNotExist:
+            return
+
+        common_models.MutationAttachment.objects.create(
+            attachment=attachment, mutation=mutation
+        )
diff --git a/api/tests/common/test_tasks.py b/api/tests/common/test_tasks.py
index fc62d901b9..cfb91470f3 100644
--- a/api/tests/common/test_tasks.py
+++ b/api/tests/common/test_tasks.py
@@ -1,6 +1,7 @@
 import pytest
 import datetime
 
+from funkwhale_api.common import models
 from funkwhale_api.common import serializers
 from funkwhale_api.common import signals
 from funkwhale_api.common import tasks
@@ -68,21 +69,27 @@ def test_cannot_apply_already_applied_migration(factories):
 
 def test_prune_unattached_attachments(factories, settings, now):
     settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY = 5
+    prunable_date = now - datetime.timedelta(
+        seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY
+    )
     attachments = [
         # attached, kept
         factories["music.Album"]().attachment_cover,
         # recent, kept
         factories["common.Attachment"](),
         # too old, pruned
-        factories["common.Attachment"](
-            creation_date=now
-            - datetime.timedelta(seconds=settings.ATTACHMENTS_UNATTACHED_PRUNE_DELAY)
-        ),
+        factories["common.Attachment"](creation_date=prunable_date),
+        # attached to a mutation, kept even if old
+        models.MutationAttachment.objects.create(
+            mutation=factories["common.Mutation"](payload={}),
+            attachment=factories["common.Attachment"](creation_date=prunable_date),
+        ).attachment,
     ]
 
     tasks.prune_unattached_attachments()
 
     attachments[0].refresh_from_db()
     attachments[1].refresh_from_db()
+    attachments[3].refresh_from_db()
     with pytest.raises(attachments[2].DoesNotExist):
         attachments[2].refresh_from_db()
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index a7fa02cc06..2d32f42cff 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -422,3 +422,8 @@ def clear_license_cache(db):
     licenses._cache = None
     yield
     licenses._cache = None
+
+
+@pytest.fixture
+def faker():
+    return factory.Faker._get_faker()
diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py
index 3bbdd48682..a632c5153d 100644
--- a/api/tests/federation/test_routes.py
+++ b/api/tests/federation/test_routes.py
@@ -537,7 +537,7 @@ def test_inbox_update_album(factories, mocker):
         "funkwhale_api.music.tasks.update_library_entity"
     )
     activity = factories["federation.Activity"]()
-    obj = factories["music.Album"](attributed=True)
+    obj = factories["music.Album"](attributed=True, attachment_cover=None)
     actor = obj.attributed_to
     data = serializers.AlbumSerializer(obj).data
     data["name"] = "New title"
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index 34460a5a66..b7c4c4a9e3 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -610,6 +610,47 @@ def test_activity_pub_album_serializer_to_ap(factories):
     assert serializer.data == expected
 
 
+def test_activity_pub_album_serializer_from_ap_update(factories, faker):
+    album = factories["music.Album"](attributed=True)
+    released = faker.date_object()
+    payload = {
+        "@context": jsonld.get_default_context(),
+        "type": "Album",
+        "id": album.fid,
+        "name": faker.sentence(),
+        "cover": {"type": "Link", "mediaType": "image/jpeg", "href": faker.url()},
+        "musicbrainzId": faker.uuid4(),
+        "published": album.creation_date.isoformat(),
+        "released": released.isoformat(),
+        "artists": [
+            serializers.ArtistSerializer(
+                album.artist, context={"include_ap_context": False}
+            ).data
+        ],
+        "attributedTo": album.attributed_to.fid,
+        "tag": [
+            {"type": "Hashtag", "name": "#Punk"},
+            {"type": "Hashtag", "name": "#Rock"},
+        ],
+    }
+    serializer = serializers.AlbumSerializer(album, data=payload)
+    assert serializer.is_valid(raise_exception=True) is True
+
+    serializer.save()
+
+    album.refresh_from_db()
+
+    assert album.title == payload["name"]
+    assert str(album.mbid) == payload["musicbrainzId"]
+    assert album.release_date == released
+    assert album.attachment_cover.url == payload["cover"]["href"]
+    assert album.attachment_cover.mimetype == payload["cover"]["mediaType"]
+    assert sorted(album.tagged_items.values_list("tag__name", flat=True)) == [
+        "Punk",
+        "Rock",
+    ]
+
+
 def test_activity_pub_track_serializer_to_ap(factories):
     track = factories["music.Track"](
         license="cc-by-4.0",
diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py
index 3a86f3bf86..ff2982dffc 100644
--- a/api/tests/music/test_mutations.py
+++ b/api/tests/music/test_mutations.py
@@ -176,3 +176,22 @@ def test_perm_checkers_can_approve(
     obj = factories["music.Track"](**obj_kwargs)
 
     assert mutations.can_approve(obj, actor=actor) is expected
+
+
+def test_mutation_set_attachment_cover(factories, now, mocker):
+    new_attachment = factories["common.Attachment"](actor__local=True)
+    obj = factories["music.Album"]()
+    old_attachment = obj.attachment_cover
+    mutation = factories["common.Mutation"](
+        type="update", target=obj, payload={"cover": new_attachment.uuid}
+    )
+
+    # new attachment should be linked to mutation, to avoid being pruned
+    # before being applied
+    assert new_attachment.mutation_attachment.mutation == mutation
+
+    mutation.apply()
+    obj.refresh_from_db()
+
+    assert obj.attachment_cover == new_attachment
+    assert mutation.previous_state["cover"] == old_attachment.uuid
diff --git a/changes/changelog.d/588.enhancement b/changes/changelog.d/588.enhancement
new file mode 100644
index 0000000000..1ce18953de
--- /dev/null
+++ b/changes/changelog.d/588.enhancement
@@ -0,0 +1 @@
+Support modifying album cover art through the web UI (#588)
diff --git a/front/src/components/common/AttachmentInput.vue b/front/src/components/common/AttachmentInput.vue
new file mode 100644
index 0000000000..47b3253a25
--- /dev/null
+++ b/front/src/components/common/AttachmentInput.vue
@@ -0,0 +1,99 @@
+<template>
+  <div>
+    <div v-if="errors.length > 0" class="ui negative message">
+      <div class="header"><translate translate-context="Content/*/Error message.Title">Your attachment cannot be saved</translate></div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <div class="ui stackable two column grid">
+      <div class="column" v-if="value && value === initialValue">
+        <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">Current file</translate></h3>
+        <img class="ui image" v-if="value" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" />
+      </div>
+      <div class="column" v-else-if="attachment">
+        <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3>
+        <img class="ui image" v-if="attachment && attachment.square_crop" :src="$store.getters['instance/absoluteUrl'](attachment.medium_square_crop)" />
+      </div>
+      <div class="column" v-if="!attachment">
+        <div class="ui basic segment">
+          <h3 class="ui header"><translate translate-context="Content/*/Title/Noun">New file</translate></h3>
+          <p><translate translate-context="Content/*/Paragraph">PNG or JPG. At most 5MB. Will be downscaled to 400x400px.</translate></p>
+          <input class="ui input" ref="attachment" type="file" accept="image/x-png,image/jpeg" @change="submit" />
+          <div v-if="isLoading" class="ui active inverted dimmer">
+            <div class="ui indeterminate text loader">
+              <translate translate-context="Content/*/*/Noun">Uploading file…</translate>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+</template>
+<script>
+import axios from 'axios'
+
+export default {
+  props: ['value', 'initialValue'],
+  data () {
+    return {
+      attachment: null,
+      isLoading: false,
+      errors: [],
+    }
+  },
+  methods: {
+    submit() {
+      this.isLoading = true
+      this.errors = []
+      let self = this
+      this.file = this.$refs.attachment.files[0]
+      let formData = new FormData()
+      formData.append("file", this.file)
+      axios
+        .post(`attachments/`, formData, {
+          headers: {
+            "Content-Type": "multipart/form-data"
+          }
+        })
+        .then(
+          response => {
+            this.isLoading = false
+            self.attachment = response.data
+            self.$emit('input', self.attachment.uuid)
+          },
+          error => {
+            self.isLoading = false
+            self.errors = error.backendErrors
+          }
+        )
+    },
+    remove() {
+      this.isLoading = true
+      this.errors = []
+      let self = this
+      axios.delete(`attachments/${this.attachment.uuid}/`)
+        .then(
+          response => {
+            this.isLoading = false
+            self.attachment = null
+            self.$emit('delete')
+          },
+          error => {
+            self.isLoading = false
+            self.errors = error.backendErrors
+          }
+        )
+    },
+  },
+  watch: {
+    value (v) {
+      if (this.attachment && v === this.initialValue) {
+        // we had a reset to initial value
+        this.remove()
+      }
+    }
+  }
+}
+</script>
diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue
index fc5efea555..20435a039e 100644
--- a/front/src/components/library/EditCard.vue
+++ b/front/src/components/library/EditCard.vue
@@ -53,20 +53,37 @@
             <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>
+              <template v-if="field.config.type === 'attachment' && field.oldRepr">
+                <img class="ui image" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.oldRepr}/proxy?next=medium_square_crop`)" />
+              </template>
+              <template v-else>
+                <span v-if="!part.added" v-for="part in field.diff" :class="['diff', {removed: part.removed}]">
+                  {{ part.value }}
+                </span>
+              </template>
             </td>
             <td v-else>
               <translate translate-context="*/*/*">N/A</translate>
             </td>
 
             <td v-if="field.diff" :title="field.newRepr">
-              <span v-if="!part.removed" v-for="part in field.diff" :class="['diff', {added: part.added}]">
-                {{ part.value }}
-              </span>
+              <template v-if="field.config.type === 'attachment' && field.newRepr">
+                <img class="ui image" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)" />
+              </template>
+              <template v-else>
+                <span v-if="!part.removed" v-for="part in field.diff" :class="['diff', {added: part.added}]">
+                  {{ part.value }}
+                </span>
+              </template>
+            </td>
+            <td v-else :title="field.newRepr">
+              <template v-if="field.config.type === 'attachment' && field.newRepr">
+                <img class="ui image" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)" />
+              </template>
+              <template v-else>
+                {{ field.newRepr }}
+              </template>
             </td>
-            <td v-else :title="field.newRepr">{{ field.newRepr }}</td>
           </tr>
         </tbody>
       </table>
@@ -171,6 +188,7 @@ export default {
         let getValueRepr = fieldConfig.getValueRepr || dummyRepr
         let d = {
           id: f,
+          config: fieldConfig
         }
         if (previousState && previousState[f]) {
           d.old = previousState[f]
diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue
index ea9fc76969..6f4afccacb 100644
--- a/front/src/components/library/EditForm.vue
+++ b/front/src/components/library/EditForm.vue
@@ -76,6 +76,17 @@
             <translate translate-context="Content/Library/Button.Label">Clear</translate>
           </button>
 
+        </template>
+        <template v-else-if="fieldConfig.type === 'attachment'">
+          <label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
+          <attachment-input
+            v-model="values[fieldConfig.id]"
+            :initial-value="initialValues[fieldConfig.id]"
+            :required="fieldConfig.required"
+            :name="fieldConfig.id"
+            :id="fieldConfig.id"
+            @delete="values[fieldConfig.id] = initialValues[fieldConfig.id]"></attachment-input>
+
         </template>
         <template v-else-if="fieldConfig.type === 'tags'">
           <label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
@@ -120,6 +131,7 @@
 import $ from 'jquery'
 import _ from '@/lodash'
 import axios from "axios"
+import AttachmentInput from '@/components/common/AttachmentInput'
 import EditList from '@/components/library/EditList'
 import EditCard from '@/components/library/EditCard'
 import TagsSelector from '@/components/library/TagsSelector'
@@ -132,7 +144,8 @@ export default {
   components: {
     EditList,
     EditCard,
-    TagsSelector
+    TagsSelector,
+    AttachmentInput
   },
   data() {
     return {
diff --git a/front/src/edits.js b/front/src/edits.js
index 5c9e9be880..8449677129 100644
--- a/front/src/edits.js
+++ b/front/src/edits.js
@@ -43,6 +43,19 @@ export default {
             label: this.$pgettext('Content/*/*/Noun', 'Release date'),
             getValue: (obj) => { return obj.release_date }
           },
+          {
+            id: 'cover',
+            type: 'attachment',
+            required: false,
+            label: this.$pgettext('Content/*/*/Noun', 'Cover'),
+            getValue: (obj) => {
+              if (obj.cover) {
+                return obj.cover.uuid
+              } else {
+                return null
+              }
+            }
+          },
           {
             id: 'tags',
             type: 'tags',
-- 
GitLab