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): ...@@ -175,7 +175,12 @@ def get_file_path(instance, filename):
class AttachmentQuerySet(models.QuerySet): class AttachmentQuerySet(models.QuerySet):
def attached(self, include=True): def attached(self, include=True):
related_fields = ["covered_album", "mutation_attachment"] related_fields = [
"covered_album",
"mutation_attachment",
"covered_track",
"covered_artist",
]
query = None query = None
for field in related_fields: for field in related_fields:
field_query = ~models.Q(**{field: None}) field_query = ~models.Q(**{field: None})
...@@ -195,7 +200,7 @@ class AttachmentQuerySet(models.QuerySet): ...@@ -195,7 +200,7 @@ class AttachmentQuerySet(models.QuerySet):
class Attachment(models.Model): class Attachment(models.Model):
# Remote URL where the attachment can be fetched # 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) uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
# Actor associated with the attachment # Actor associated with the attachment
actor = models.ForeignKey( actor = models.ForeignKey(
......
...@@ -85,8 +85,6 @@ class MutationSerializer(serializers.Serializer): ...@@ -85,8 +85,6 @@ class MutationSerializer(serializers.Serializer):
class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
serialized_relations = {}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# we force partial mode, because update mutations are partial # we force partial mode, because update mutations are partial
kwargs.setdefault("partial", True) kwargs.setdefault("partial", True)
...@@ -105,13 +103,14 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): ...@@ -105,13 +103,14 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
return super().validate(validated_data) return super().validate(validated_data)
def db_serialize(self, validated_data): def db_serialize(self, validated_data):
serialized_relations = self.get_serialized_relations()
data = {} data = {}
# ensure model fields are serialized properly # ensure model fields are serialized properly
for key, value in list(validated_data.items()): for key, value in list(validated_data.items()):
if not isinstance(value, models.Model): if not isinstance(value, models.Model):
data[key] = value data[key] = value
continue continue
field = self.serialized_relations[key] field = serialized_relations[key]
data[key] = getattr(value, field) data[key] = getattr(value, field)
return data return data
...@@ -120,7 +119,7 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): ...@@ -120,7 +119,7 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
# we use our serialized_relations configuration # we use our serialized_relations configuration
# to ensure we store ids instead of model instances in our json # to ensure we store ids instead of model instances in our json
# payload # payload
for field, attr in self.serialized_relations.items(): for field, attr in self.get_serialized_relations().items():
try: try:
obj = data[field] obj = data[field]
except KeyError: except KeyError:
...@@ -139,10 +138,13 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): ...@@ -139,10 +138,13 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
return get_update_previous_state( return get_update_previous_state(
obj, obj,
*list(validated_data.keys()), *list(validated_data.keys()),
serialized_relations=self.serialized_relations, serialized_relations=self.get_serialized_relations(),
handlers=self.get_previous_state_handlers(), handlers=self.get_previous_state_handlers(),
) )
def get_serialized_relations(self):
return {}
def get_previous_state_handlers(self): def get_previous_state_handlers(self):
return {} return {}
......
from django.core.files.base import ContentFile
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
import bleach.sanitizer import bleach.sanitizer
import logging
import markdown import markdown
import os import os
import shutil import shutil
...@@ -13,6 +15,8 @@ from django.conf import settings ...@@ -13,6 +15,8 @@ from django.conf import settings
from django import urls from django import urls
from django.db import models, transaction from django.db import models, transaction
logger = logging.getLogger(__name__)
def rename_file(instance, field_name, new_name, allow_missing_file=False): def rename_file(instance, field_name, new_name, allow_missing_file=False):
field = getattr(instance, field_name) field = getattr(instance, field_name)
...@@ -306,3 +310,41 @@ def attach_content(obj, field, content_data): ...@@ -306,3 +310,41 @@ def attach_content(obj, field, content_data):
setattr(obj, field, content_obj) setattr(obj, field, content_obj)
obj.save(update_fields=[field]) obj.save(update_fields=[field])
return content_obj 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__) ...@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
class LinkSerializer(jsonld.JsonLdSerializer): 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) href = serializers.URLField(max_length=500)
mediaType = serializers.CharField() mediaType = serializers.CharField()
...@@ -817,6 +817,17 @@ def include_content(repr, content_obj): ...@@ -817,6 +817,17 @@ def include_content(repr, content_obj):
repr["mediaType"] = "text/html" 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): class TruncatedCharField(serializers.CharField):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.truncate_length = kwargs.pop("truncate_length") self.truncate_length = kwargs.pop("truncate_length")
...@@ -877,6 +888,23 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): ...@@ -877,6 +888,23 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
] ]
def validate_updated_data(self, instance, validated_data): 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 return validated_data
def validate(self, data): def validate(self, data):
...@@ -890,15 +918,26 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): ...@@ -890,15 +918,26 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
class ArtistSerializer(MusicEntitySerializer): class ArtistSerializer(MusicEntitySerializer):
image = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
)
updateable_fields = [ updateable_fields = [
("name", "name"), ("name", "name"),
("musicbrainzId", "mbid"), ("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"), ("attributedTo", "attributed_to"),
("image", "attachment_cover"),
] ]
class Meta: class Meta:
model = music_models.Artist 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): def to_representation(self, instance):
d = { d = {
...@@ -913,6 +952,7 @@ class ArtistSerializer(MusicEntitySerializer): ...@@ -913,6 +952,7 @@ class ArtistSerializer(MusicEntitySerializer):
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
include_content(d, instance.description) include_content(d, instance.description)
include_image(d, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() d["@context"] = jsonld.get_default_context()
return d return d
...@@ -921,6 +961,7 @@ class ArtistSerializer(MusicEntitySerializer): ...@@ -921,6 +961,7 @@ class ArtistSerializer(MusicEntitySerializer):
class AlbumSerializer(MusicEntitySerializer): class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False) released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1) artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
# XXX: 1.0 rename to image
cover = LinkSerializer( cover = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False allowed_mimetypes=["image/*"], allow_null=True, required=False
) )
...@@ -970,30 +1011,12 @@ class AlbumSerializer(MusicEntitySerializer): ...@@ -970,30 +1011,12 @@ class AlbumSerializer(MusicEntitySerializer):
"href": instance.attachment_cover.download_url_original, "href": instance.attachment_cover.download_url_original,
"mediaType": instance.attachment_cover.mimetype or "image/jpeg", "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): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() d["@context"] = jsonld.get_default_context()
return d 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): class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
...@@ -1002,6 +1025,9 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1002,6 +1025,9 @@ class TrackSerializer(MusicEntitySerializer):
album = AlbumSerializer() album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(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 = [ updateable_fields = [
("name", "title"), ("name", "title"),
...@@ -1011,6 +1037,7 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1011,6 +1037,7 @@ class TrackSerializer(MusicEntitySerializer):
("position", "position"), ("position", "position"),
("copyright", "copyright"), ("copyright", "copyright"),
("license", "license"), ("license", "license"),
("image", "attachment_cover"),
] ]
class Meta: class Meta:
...@@ -1024,6 +1051,7 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1024,6 +1051,7 @@ class TrackSerializer(MusicEntitySerializer):
"disc": jsonld.first_val(contexts.FW.disc), "disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license), "license": jsonld.first_id(contexts.FW.license),
"position": jsonld.first_val(contexts.FW.position), "position": jsonld.first_val(contexts.FW.position),
"image": jsonld.first_obj(contexts.AS.image),
}, },
) )
...@@ -1054,6 +1082,7 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1054,6 +1082,7 @@ class TrackSerializer(MusicEntitySerializer):
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
include_content(d, instance.description) include_content(d, instance.description)
include_image(d, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() d["@context"] = jsonld.get_default_context()
return d return d
......
...@@ -222,9 +222,12 @@ class MusicLibraryViewSet( ...@@ -222,9 +222,12 @@ class MusicLibraryViewSet(
queryset=music_models.Track.objects.select_related( queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to", "album__artist__attributed_to",
"artist__attributed_to", "artist__attributed_to",
"artist__attachment_cover",
"attachment_cover",
"album__attributed_to", "album__attributed_to",
"attributed_to", "attributed_to",
"album__attachment_cover", "album__attachment_cover",
"album__artist__attachment_cover",
"description", "description",
).prefetch_related( ).prefetch_related(
"tagged_items__tag", "tagged_items__tag",
...@@ -283,6 +286,9 @@ class MusicUploadViewSet( ...@@ -283,6 +286,9 @@ class MusicUploadViewSet(
"track__album__artist", "track__album__artist",
"track__description", "track__description",
"track__album__attachment_cover", "track__album__attachment_cover",
"track__album__artist__attachment_cover",
"track__artist__attachment_cover",
"track__attachment_cover",
) )
serializer_class = serializers.UploadSerializer serializer_class = serializers.UploadSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -303,7 +309,9 @@ class MusicArtistViewSet( ...@@ -303,7 +309,9 @@ class MusicArtistViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() 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 serializer_class = serializers.ArtistSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -314,7 +322,7 @@ class MusicAlbumViewSet( ...@@ -314,7 +322,7 @@ class MusicAlbumViewSet(
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related( queryset = music_models.Album.objects.local().select_related(
"artist__description", "description" "artist__description", "description", "artist__attachment_cover"
) )
serializer_class = serializers.AlbumSerializer serializer_class = serializers.AlbumSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -326,7 +334,14 @@ class MusicTrackViewSet( ...@@ -326,7 +334,14 @@ class MusicTrackViewSet(
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related( 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 serializer_class = serializers.TrackSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -390,6 +390,7 @@ class ManageArtistSerializer( ...@@ -390,6 +390,7 @@ class ManageArtistSerializer(
tracks = ManageNestedTrackSerializer(many=True) tracks = ManageNestedTrackSerializer(many=True)
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
cover = music_serializers.cover_field
class Meta: class Meta:
model = music_models.Artist model = music_models.Artist
...@@ -398,6 +399,7 @@ class ManageArtistSerializer( ...@@ -398,6 +399,7 @@ class ManageArtistSerializer(
"tracks", "tracks",
"attributed_to", "attributed_to",
"tags", "tags",
"cover",
] ]
def get_tags(self, obj): def get_tags(self, obj):
...@@ -447,6 +449,7 @@ class ManageTrackSerializer( ...@@ -447,6 +449,7 @@ class ManageTrackSerializer(
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
cover = music_serializers.cover_field
class Meta: class Meta:
model = music_models.Track model = music_models.Track
...@@ -456,6 +459,7 @@ class ManageTrackSerializer( ...@@ -456,6 +459,7 @@ class ManageTrackSerializer(
"attributed_to", "attributed_to",
"uploads_count", "uploads_count",
"tags", "tags",
"cover",
] ]
def get_uploads_count(self, obj): def get_uploads_count(self, obj):
......
...@@ -64,7 +64,7 @@ class ManageArtistViewSet( ...@@ -64,7 +64,7 @@ class ManageArtistViewSet(
queryset = ( queryset = (
music_models.Artist.objects.all() music_models.Artist.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to") .select_related("attributed_to", "attachment_cover",)
.prefetch_related( .prefetch_related(
"tracks", "tracks",
Prefetch( Prefetch(
...@@ -164,7 +164,11 @@ class ManageTrackViewSet( ...@@ -164,7 +164,11 @@ class ManageTrackViewSet(
music_models.Track.objects.all() music_models.Track.objects.all()
.order_by("-id") .order_by("-id")
.select_related( .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)) .annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0))
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
......
...@@ -64,6 +64,7 @@ class ArtistFactory( ...@@ -64,6 +64,7 @@ class ArtistFactory(
mbid = factory.Faker("uuid4") mbid = factory.Faker("uuid4")
fid = factory.Faker("federation_url") fid = factory.Faker("federation_url")
playable = playable_factory("track__album__artist") playable = playable_factory("track__album__artist")
attachment_cover = factory.SubFactory(common_factories.AttachmentFactory)
class Meta: class Meta:
model = "music.Artist"