Commit 71b400a9 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

See #170: cover on tracks and artists

parent db1cb30d
# Generated by Django 2.2.9 on 2020-01-16 16:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0006_content'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='url',
field=models.URLField(max_length=500, null=True),
),
]
......@@ -175,7 +175,12 @@ def get_file_path(instance, filename):
class AttachmentQuerySet(models.QuerySet):
def attached(self, include=True):
related_fields = ["covered_album", "mutation_attachment"]
related_fields = [
"covered_album",
"mutation_attachment",
"covered_track",
"covered_artist",
]
query = None
for field in related_fields:
field_query = ~models.Q(**{field: None})
......@@ -195,7 +200,7 @@ class AttachmentQuerySet(models.QuerySet):
class Attachment(models.Model):
# Remote URL where the attachment can be fetched
url = models.URLField(max_length=500, unique=True, null=True)
url = models.URLField(max_length=500, null=True)
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
# Actor associated with the attachment
actor = models.ForeignKey(
......
......@@ -85,8 +85,6 @@ class MutationSerializer(serializers.Serializer):
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)
......@@ -105,13 +103,14 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
return super().validate(validated_data)
def db_serialize(self, validated_data):
serialized_relations = self.get_serialized_relations()
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]
field = serialized_relations[key]
data[key] = getattr(value, field)
return data
......@@ -120,7 +119,7 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
# 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():
for field, attr in self.get_serialized_relations().items():
try:
obj = data[field]
except KeyError:
......@@ -139,10 +138,13 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
return get_update_previous_state(
obj,
*list(validated_data.keys()),
serialized_relations=self.serialized_relations,
serialized_relations=self.get_serialized_relations(),
handlers=self.get_previous_state_handlers(),
)
def get_serialized_relations(self):
return {}
def get_previous_state_handlers(self):
return {}
......
from django.core.files.base import ContentFile
from django.utils.deconstruct import deconstructible
import bleach.sanitizer
import logging
import markdown
import os
import shutil
......@@ -13,6 +15,8 @@ from django.conf import settings
from django import urls
from django.db import models, transaction
logger = logging.getLogger(__name__)
def rename_file(instance, field_name, new_name, allow_missing_file=False):
field = getattr(instance, field_name)
......@@ -306,3 +310,41 @@ def attach_content(obj, field, content_data):
setattr(obj, field, content_obj)
obj.save(update_fields=[field])
return content_obj
@transaction.atomic
def attach_file(obj, field, file_data, fetch=False):
from . import models
from . import tasks
existing = getattr(obj, "{}_id".format(field))
if existing:
getattr(obj, field).delete()
if not file_data:
return
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(file_data["mimetype"], "jpg")
attachment = models.Attachment(mimetype=file_data["mimetype"])
filename = "cover-{}.{}".format(obj.uuid, extension)
if "url" in file_data:
attachment.url = file_data["url"]
else:
f = ContentFile(file_data["content"])
attachment.file.save(filename, f, save=False)
if not attachment.file and fetch:
try:
tasks.fetch_remote_attachment(attachment, filename=filename, save=False)
except Exception as e:
logger.warn("Cannot download attachment at url %s: %s", attachment.url, e)
attachment = None
if attachment:
attachment.save()
setattr(obj, field, attachment)
obj.save(update_fields=[field])
return attachment
......@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class LinkSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Link])
type = serializers.ChoiceField(choices=[contexts.AS.Link, contexts.AS.Image])
href = serializers.URLField(max_length=500)
mediaType = serializers.CharField()
......@@ -817,6 +817,17 @@ def include_content(repr, content_obj):
repr["mediaType"] = "text/html"
def include_image(repr, attachment):
if attachment:
repr["image"] = {
"type": "Image",
"href": attachment.download_url_original,
"mediaType": attachment.mimetype or "image/jpeg",
}
else:
repr["image"] = None
class TruncatedCharField(serializers.CharField):
def __init__(self, *args, **kwargs):
self.truncate_length = kwargs.pop("truncate_length")
......@@ -877,6 +888,23 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
]
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 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
def validate(self, data):
......@@ -890,15 +918,26 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
class ArtistSerializer(MusicEntitySerializer):
image = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
)
updateable_fields = [
("name", "name"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("image", "attachment_cover"),
]
class Meta:
model = music_models.Artist
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"image": jsonld.first_obj(contexts.AS.image),
},
)
def to_representation(self, instance):
d = {
......@@ -913,6 +952,7 @@ class ArtistSerializer(MusicEntitySerializer):
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
include_image(d, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
......@@ -921,6 +961,7 @@ class ArtistSerializer(MusicEntitySerializer):
class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
# XXX: 1.0 rename to image
cover = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
)
......@@ -970,30 +1011,12 @@ class AlbumSerializer(MusicEntitySerializer):
"href": instance.attachment_cover.download_url_original,
"mediaType": instance.attachment_cover.mimetype or "image/jpeg",
}
include_image(d, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
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)
......@@ -1002,6 +1025,9 @@ class TrackSerializer(MusicEntitySerializer):
album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False)
image = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
)
updateable_fields = [
("name", "title"),
......@@ -1011,6 +1037,7 @@ class TrackSerializer(MusicEntitySerializer):
("position", "position"),
("copyright", "copyright"),
("license", "license"),
("image", "attachment_cover"),
]
class Meta:
......@@ -1024,6 +1051,7 @@ class TrackSerializer(MusicEntitySerializer):
"disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license),
"position": jsonld.first_val(contexts.FW.position),
"image": jsonld.first_obj(contexts.AS.image),
},
)
......@@ -1054,6 +1082,7 @@ class TrackSerializer(MusicEntitySerializer):
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
include_image(d, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
......
......@@ -222,9 +222,12 @@ class MusicLibraryViewSet(
queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to",
"artist__attributed_to",
"artist__attachment_cover",
"attachment_cover",
"album__attributed_to",
"attributed_to",
"album__attachment_cover",
"album__artist__attachment_cover",
"description",
).prefetch_related(
"tagged_items__tag",
......@@ -283,6 +286,9 @@ class MusicUploadViewSet(
"track__album__artist",
"track__description",
"track__album__attachment_cover",
"track__album__artist__attachment_cover",
"track__artist__attachment_cover",
"track__attachment_cover",
)
serializer_class = serializers.UploadSerializer
lookup_field = "uuid"
......@@ -303,7 +309,9 @@ class MusicArtistViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Artist.objects.local().select_related("description")
queryset = music_models.Artist.objects.local().select_related(
"description", "attachment_cover"
)
serializer_class = serializers.ArtistSerializer
lookup_field = "uuid"
......@@ -314,7 +322,7 @@ class MusicAlbumViewSet(
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related(
"artist__description", "description"
"artist__description", "description", "artist__attachment_cover"
)
serializer_class = serializers.AlbumSerializer
lookup_field = "uuid"
......@@ -326,7 +334,14 @@ class MusicTrackViewSet(
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related(
"album__artist", "album__description", "artist__description", "description"
"album__artist",
"album__description",
"artist__description",
"description",
"attachment_cover",
"album__artist__attachment_cover",
"album__attachment_cover",
"artist__attachment_cover",
)
serializer_class = serializers.TrackSerializer
lookup_field = "uuid"
......@@ -390,6 +390,7 @@ class ManageArtistSerializer(
tracks = ManageNestedTrackSerializer(many=True)
attributed_to = ManageBaseActorSerializer()
tags = serializers.SerializerMethodField()
cover = music_serializers.cover_field
class Meta:
model = music_models.Artist
......@@ -398,6 +399,7 @@ class ManageArtistSerializer(
"tracks",
"attributed_to",
"tags",
"cover",
]
def get_tags(self, obj):
......@@ -447,6 +449,7 @@ class ManageTrackSerializer(
attributed_to = ManageBaseActorSerializer()
uploads_count = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
cover = music_serializers.cover_field
class Meta:
model = music_models.Track
......@@ -456,6 +459,7 @@ class ManageTrackSerializer(
"attributed_to",
"uploads_count",
"tags",
"cover",
]
def get_uploads_count(self, obj):
......
......@@ -64,7 +64,7 @@ class ManageArtistViewSet(
queryset = (
music_models.Artist.objects.all()
.order_by("-id")
.select_related("attributed_to")
.select_related("attributed_to", "attachment_cover",)
.prefetch_related(
"tracks",
Prefetch(
......@@ -164,7 +164,11 @@ class ManageTrackViewSet(
music_models.Track.objects.all()
.order_by("-id")
.select_related(
"attributed_to", "artist", "album__artist", "album__attachment_cover"
"attributed_to",
"artist",
"album__artist",
"album__attachment_cover",
"attachment_cover",
)
.annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0))
.prefetch_related(music_views.TAG_PREFETCH)
......
......@@ -64,6 +64,7 @@ class ArtistFactory(
mbid = factory.Faker("uuid4")
fid = factory.Faker("federation_url")
playable = playable_factory("track__album__artist")
attachment_cover = factory.SubFactory(common_factories.AttachmentFactory)
class Meta:
model = "music.Artist"
......@@ -111,6 +112,7 @@ class TrackFactory(
album = factory.SubFactory(AlbumFactory)
position = 1
playable = playable_factory("track")
attachment_cover = factory.SubFactory(common_factories.AttachmentFactory)
class Meta:
model = "music.Track"
......
......@@ -723,6 +723,7 @@ class TrackMetadataSerializer(serializers.Serializer):
continue
if v in ["", None, []]:
validated_data.pop(field)
validated_data["album"]["cover_data"] = validated_data.pop("cover_data", None)
return validated_data
......
# Generated by Django 2.2.9 on 2020-01-16 12:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0006_content'),
('music', '0046_auto_20200113_1018'),
]
operations = [
migrations.AddField(
model_name='artist',
name='attachment_cover',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='covered_artist', to='common.Attachment'),
),
migrations.AddField(
model_name='track',
name='attachment_cover',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='covered_track', to='common.Attachment'),
),
migrations.AlterField(
model_name='album',
name='attachment_cover',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='covered_album', to='common.Attachment'),
),
]
......@@ -230,6 +230,13 @@ class Artist(APIModelMixin):
description = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
attachment_cover = models.ForeignKey(
"common.Attachment",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="covered_artist",
)
api = musicbrainz.api.artists
objects = ArtistQuerySet.as_manager()
......@@ -248,6 +255,10 @@ class Artist(APIModelMixin):
kwargs.update({"name": name})
return cls.objects.get_or_create(name__iexact=name, defaults=kwargs)
@property
def cover(self):
return self.attachment_cover
def import_artist(v):
a = Artist.get_or_create_from_api(mbid=v[0]["artist"]["id"])[0]
......@@ -358,44 +369,6 @@ class Album(APIModelMixin):
}
objects = AlbumQuerySet.as_manager()
def get_image(self, data=None):
from funkwhale_api.common import tasks as common_tasks
attachment = None
if data:
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(data["mimetype"], "jpg")
attachment = common_models.Attachment(mimetype=data["mimetype"])
f = None
filename = "{}.{}".format(self.uuid, extension)
if data.get("content"):
# we have to cover itself
f = ContentFile(data["content"])
attachment.file.save(filename, f, save=False)
elif data.get("url"):
attachment.url = data.get("url")
# we can fetch from a url
try:
common_tasks.fetch_remote_attachment(
attachment, filename=filename, save=False
)
except Exception as e:
logger.warn(
"Cannot download cover at url %s: %s", data.get("url"), e
)
return
elif self.mbid:
image_data = musicbrainz.api.images.get_front(str(self.mbid))
f = ContentFile(image_data)
attachment = common_models.Attachment(mimetype="image/jpeg")
attachment.file.save("{0}.jpg".format(self.mbid), f, save=False)
if attachment and attachment.file:
attachment.save()
self.attachment_cover = attachment
self.save(update_fields=["attachment_cover"])
return self.attachment_cover.file
@property
def cover(self):
return self.attachment_cover
......@@ -518,6 +491,13 @@ class Track(APIModelMixin):
description = models.ForeignKey(
"common.Content", null=True, blank=True, on_delete=models.SET_NULL
)
attachment_cover = models.ForeignKey(
"common.Attachment",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="covered_track",
)
federation_namespace = "tracks"
musicbrainz_model = "recording"
......@@ -572,6 +552,10 @@ class Track(APIModelMixin):
except AttributeError:
return "{} - {}".format(self.artist.name, self.title)
@property
def cover(self):
return self.attachment_cover
def get_activity_url(self):
if self.mbid:
return "https://musicbrainz.org/recording/{}".format(self.mbid)
......
......@@ -59,17 +59,66 @@ class DescriptionMutation(mutations.UpdateMutationSerializer):
return r
class CoverMutation(mutations.UpdateMutationSerializer):
cover = common_serializers.RelatedField(
"uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None
)
def get_serialized_relations(self):
serialized_relations = super().get_serialized_relations()
serialized_relations["cover"] = "uuid"
return serialized_relations
def get_previous_state_handlers(self):
handlers = super().get_previous_state_handlers()
handlers["cover"] = (
lambda obj: str(obj.attachment_cover.uuid) if obj.attachment_cover else None
)
return handlers
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: