See #170: cover on tracks and artists

This commit is contained in:
Eliot Berriot 2020-01-17 16:27:11 +01:00
parent db1cb30df8
commit 71b400a9b8
34 changed files with 582 additions and 254 deletions

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,30 @@
# 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'),
),
]

View file

@ -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)

View file

@ -59,55 +59,15 @@ class DescriptionMutation(mutations.UpdateMutationSerializer):
return r
@mutations.registry.connect(
"update",
models.Track,
perm_checkers={"suggest": can_suggest, "approve": can_approve},
)
class TrackMutationSerializer(TagMutation, DescriptionMutation):
serialized_relations = {"license": "code"}
class Meta:
model = models.Track
fields = ["license", "title", "position", "copyright", "tags", "description"]
def post_apply(self, obj, validated_data):
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(TagMutation, DescriptionMutation):
class Meta:
model = models.Artist
fields = ["name", "tags", "description"]
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(TagMutation, DescriptionMutation):
class CoverMutation(mutations.UpdateMutationSerializer):
cover = common_serializers.RelatedField(
"uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None
)
serialized_relations = {"cover": "uuid"}
class Meta:
model = models.Album
fields = ["title", "release_date", "tags", "cover", "description"]
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()
@ -116,11 +76,6 @@ class AlbumMutationSerializer(TagMutation, DescriptionMutation):
)
return handlers
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")
@ -140,3 +95,64 @@ class AlbumMutationSerializer(TagMutation, DescriptionMutation):
common_models.MutationAttachment.objects.create(
attachment=attachment, mutation=mutation
)
@mutations.registry.connect(
"update",
models.Track,
perm_checkers={"suggest": can_suggest, "approve": can_approve},
)
class TrackMutationSerializer(CoverMutation, TagMutation, DescriptionMutation):
class Meta:
model = models.Track
fields = [
"license",
"title",
"position",
"copyright",
"tags",
"description",
"cover",
]
def get_serialized_relations(self):
serialized_relations = super().get_serialized_relations()
serialized_relations["license"] = "code"
return serialized_relations
def post_apply(self, obj, validated_data):
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(CoverMutation, TagMutation, DescriptionMutation):
class Meta:
model = models.Artist
fields = ["name", "tags", "description", "cover"]
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(CoverMutation, TagMutation, DescriptionMutation):
class Meta:
model = models.Album
fields = ["title", "release_date", "tags", "cover", "description"]
def post_apply(self, obj, validated_data):
routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Album"}}, context={"album": obj}
)

View file

@ -121,6 +121,7 @@ class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serialize
name = serializers.CharField()
creation_date = serializers.DateTimeField()
is_local = serializers.BooleanField()
cover = cover_field
def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
@ -266,7 +267,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
disc_number = serializers.IntegerField()
copyright = serializers.CharField()
license = serializers.SerializerMethodField()
cover = cover_field
get_attributed_to = serialize_attributed_to
def get_artist(self, o):

View file

@ -11,6 +11,7 @@ from django.dispatch import receiver
from musicbrainzngs import ResponseError
from requests.exceptions import RequestException
from funkwhale_api import musicbrainz
from funkwhale_api.common import channels, preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import routes
@ -28,33 +29,34 @@ from . import signals
logger = logging.getLogger(__name__)
def update_album_cover(
album, source=None, cover_data=None, musicbrainz=True, replace=False
):
def populate_album_cover(album, source=None, replace=False):
if album.attachment_cover and not replace:
return
if cover_data:
return album.get_image(data=cover_data)
if source and source.startswith("file://"):
# let's look for a cover in the same directory
path = os.path.dirname(source.replace("file://", "", 1))
logger.info("[Album %s] scanning covers from %s", album.pk, path)
cover = get_cover_from_fs(path)
if cover:
return album.get_image(data=cover)
if musicbrainz and album.mbid:
return common_utils.attach_file(album, "attachment_cover", cover)
if album.mbid:
logger.info(
"[Album %s] Fetching cover from musicbrainz release %s",
album.pk,
str(album.mbid),
)
try:
logger.info(
"[Album %s] Fetching cover from musicbrainz release %s",
album.pk,
str(album.mbid),
)
return album.get_image()
image_data = musicbrainz.api.images.get_front(str(album.mbid))
except ResponseError as exc:
logger.warning(
"[Album %s] cannot fetch cover from musicbrainz: %s", album.pk, str(exc)
)
else:
return common_utils.attach_file(
album,
"attachment_cover",
{"content": image_data, "mimetype": "image/jpeg"},
fetch=True,
)
IMAGE_TYPES = [("jpg", "image/jpeg"), ("jpeg", "image/jpeg"), ("png", "image/png")]
@ -274,10 +276,8 @@ def process_upload(upload, update_denormalization=True):
# update album cover, if needed
if not track.album.attachment_cover:
update_album_cover(
track.album,
source=final_metadata.get("upload_source"),
cover_data=final_metadata.get("cover_data"),
populate_album_cover(
track.album, source=final_metadata.get("upload_source"),
)
broadcast = getter(
@ -299,6 +299,12 @@ def process_upload(upload, update_denormalization=True):
)
def get_cover(obj, field):
cover = obj.get(field)
if cover:
return {"mimetype": cover["mediaType"], "url": cover["href"]}
def federation_audio_track_to_metadata(payload, references):
"""
Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data,
@ -315,6 +321,7 @@ def federation_audio_track_to_metadata(payload, references):
"mbid": str(payload.get("musicbrainzId"))
if payload.get("musicbrainzId")
else None,
"cover_data": get_cover(payload, "image"),
"album": {
"title": payload["album"]["name"],
"fdate": payload["album"]["published"],
@ -324,6 +331,7 @@ def federation_audio_track_to_metadata(payload, references):
"mbid": str(payload["album"]["musicbrainzId"])
if payload["album"].get("musicbrainzId")
else None,
"cover_data": get_cover(payload["album"], "cover"),
"release_date": payload["album"].get("released"),
"tags": [t["name"] for t in payload["album"].get("tags", []) or []],
"artists": [
@ -331,6 +339,7 @@ def federation_audio_track_to_metadata(payload, references):
"fid": a["id"],
"name": a["name"],
"fdate": a["published"],
"cover_data": get_cover(a, "image"),
"description": a.get("description"),
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
@ -348,6 +357,7 @@ def federation_audio_track_to_metadata(payload, references):
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
"tags": [t["name"] for t in a.get("tags", []) or []],
"cover_data": get_cover(a, "image"),
}
for a in payload["artists"]
],
@ -356,9 +366,6 @@ def federation_audio_track_to_metadata(payload, references):
"fdate": payload["published"],
"tags": [t["name"] for t in payload.get("tags", []) or []],
}
cover = payload["album"].get("cover")
if cover:
new_data["cover_data"] = {"mimetype": cover["mediaType"], "url": cover["href"]}
return new_data
@ -427,11 +434,7 @@ def get_track_from_import_metadata(
):
track = _get_track(data, attributed_to=attributed_to, **forced_values)
if update_cover and track and not track.album.attachment_cover:
update_album_cover(
track.album,
source=data.get("upload_source"),
cover_data=data.get("cover_data"),
)
populate_album_cover(track.album, source=data.get("upload_source"))
return track
@ -513,6 +516,9 @@ def _get_track(data, attributed_to=None, **forced_values):
common_utils.attach_content(
artist, "description", artist_data.get("description")
)
common_utils.attach_file(
artist, "attachment_cover", artist_data.get("cover_data")
)
if "album" in forced_values:
album = forced_values["album"]
@ -550,6 +556,11 @@ def _get_track(data, attributed_to=None, **forced_values):
common_utils.attach_content(
album_artist, "description", album_artist_data.get("description")
)
common_utils.attach_file(
album_artist,
"attachment_cover",
album_artist_data.get("cover_data"),
)
# get / create album
album_data = data["album"]
@ -583,6 +594,9 @@ def _get_track(data, attributed_to=None, **forced_values):
common_utils.attach_content(
album, "description", album_data.get("description")
)
common_utils.attach_file(
album, "attachment_cover", album_data.get("cover_data")
)
# get / create track
track_title = (
@ -643,6 +657,7 @@ def _get_track(data, attributed_to=None, **forced_values):
)
tags_models.add_tags(track, *tags)
common_utils.attach_content(track, "description", data.get("description"))
common_utils.attach_file(track, "attachment_cover", data.get("cover_data"))
return track

View file

@ -113,7 +113,7 @@ class ArtistViewSet(
):
queryset = (
models.Artist.objects.all()
.prefetch_related("attributed_to")
.prefetch_related("attributed_to", "attachment_cover")
.prefetch_related(
Prefetch(
"tracks",
@ -295,7 +295,7 @@ class TrackViewSet(
queryset = (
models.Track.objects.all()
.for_nested_serialization()
.prefetch_related("attributed_to")
.prefetch_related("attributed_to", "attachment_cover")
.order_by("-creation_date")
)
serializer_class = serializers.TrackSerializer
@ -558,7 +558,12 @@ class UploadViewSet(
queryset = (
models.Upload.objects.all()
.order_by("-creation_date")
.prefetch_related("library", "track__artist", "track__album__artist")
.prefetch_related(
"library",
"track__artist",
"track__album__artist",
"track__attachment_cover",
)
)
serializer_class = serializers.UploadForOwnerSerializer
permission_classes = [