diff --git a/api/config/settings/common.py b/api/config/settings/common.py index c789c36af..b860b1c3e 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -467,3 +467,13 @@ MUSIC_DIRECTORY_SERVE_PATH = env( USERS_INVITATION_EXPIRATION_DAYS = env.int( "USERS_INVITATION_EXPIRATION_DAYS", default=14 ) + +VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = { + "square": [ + ("original", "url"), + ("square_crop", "crop__400x400"), + ("medium_square_crop", "crop__200x200"), + ("small_square_crop", "crop__50x50"), + ] +} +VERSATILEIMAGEFIELD_SETTINGS = {"create_images_on_demand": False} diff --git a/api/funkwhale_api/common/management/commands/script.py b/api/funkwhale_api/common/management/commands/script.py index cbfc78f0f..7f8d5c15d 100644 --- a/api/funkwhale_api/common/management/commands/script.py +++ b/api/funkwhale_api/common/management/commands/script.py @@ -19,7 +19,7 @@ class Command(BaseCommand): def handle(self, *args, **options): name = options["script_name"] if not name: - self.show_help() + return self.show_help() available_scripts = self.get_scripts() try: diff --git a/api/funkwhale_api/common/scripts/__init__.py b/api/funkwhale_api/common/scripts/__init__.py index e69de29bb..863256ba8 100644 --- a/api/funkwhale_api/common/scripts/__init__.py +++ b/api/funkwhale_api/common/scripts/__init__.py @@ -0,0 +1,6 @@ +from . import create_image_variations +from . import django_permissions_to_user_permissions +from . import test + + +__all__ = ["create_image_variations", "django_permissions_to_user_permissions", "test"] diff --git a/api/funkwhale_api/common/scripts/create_image_variations.py b/api/funkwhale_api/common/scripts/create_image_variations.py new file mode 100644 index 000000000..5e941ce1f --- /dev/null +++ b/api/funkwhale_api/common/scripts/create_image_variations.py @@ -0,0 +1,30 @@ +""" +Compute different sizes of image used for Album covers and User avatars +""" + +from versatileimagefield.image_warmer import VersatileImageFieldWarmer + +from funkwhale_api.music.models import Album +from funkwhale_api.users.models import User + + +MODELS = [(Album, "cover", "square"), (User, "avatar", "square")] + + +def main(command, **kwargs): + for model, attribute, key_set in MODELS: + qs = model.objects.exclude(**{"{}__isnull".format(attribute): True}) + qs = qs.exclude(**{attribute: ""}) + warmer = VersatileImageFieldWarmer( + instance_or_queryset=qs, + rendition_key_set=key_set, + image_attr=attribute, + verbose=True, + ) + command.stdout.write( + "Creating images for {} / {}".format(model.__name__, attribute) + ) + num_created, failed_to_create = warmer.warm() + command.stdout.write( + " {} created, {} in error".format(num_created, len(failed_to_create)) + ) diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index 61b5bee6c..ae47e03f2 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -18,7 +18,11 @@ class TrackFavoriteViewSet( ): serializer_class = serializers.UserTrackFavoriteSerializer - queryset = models.TrackFavorite.objects.all() + queryset = ( + models.TrackFavorite.objects.all() + .select_related("track__artist", "track__album__artist", "user") + .prefetch_related("track__files") + ) permission_classes = [ permissions.ConditionalAuthentication, permissions.OwnerPermission, diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 207b22dfb..c8dd61313 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -15,7 +15,9 @@ from django.dispatch import receiver from django.urls import reverse from django.utils import timezone from taggit.managers import TaggableManager + from versatileimagefield.fields import VersatileImageField +from versatileimagefield.image_warmer import VersatileImageFieldWarmer from funkwhale_api import downloader, musicbrainz from funkwhale_api.federation import utils as federation_utils @@ -641,3 +643,13 @@ def update_request_status(sender, instance, created, **kwargs): # let's mark the request as imported since the import is over instance.import_request.status = "imported" return instance.import_request.save(update_fields=["status"]) + + +@receiver(models.signals.post_save, sender=Album) +def warm_album_covers(sender, instance, **kwargs): + if not instance.cover: + return + album_covers_warmer = VersatileImageFieldWarmer( + instance_or_queryset=instance, rendition_key_set="square", image_attr="cover" + ) + num_created, failed_to_create = album_covers_warmer.warm() diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 14ea54d51..0661eb8f4 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -1,6 +1,7 @@ from django.db.models import Q from rest_framework import serializers from taggit.models import Tag +from versatileimagefield.serializers import VersatileImageFieldSerializer from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.users.serializers import UserBasicSerializer @@ -8,8 +9,12 @@ from funkwhale_api.users.serializers import UserBasicSerializer from . import models, tasks +cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") + + class ArtistAlbumSerializer(serializers.ModelSerializer): tracks_count = serializers.SerializerMethodField() + cover = cover_field class Meta: model = models.Album @@ -87,6 +92,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer): class AlbumSerializer(serializers.ModelSerializer): tracks = serializers.SerializerMethodField() artist = ArtistSimpleSerializer(read_only=True) + cover = cover_field class Meta: model = models.Album @@ -111,6 +117,7 @@ class AlbumSerializer(serializers.ModelSerializer): class TrackAlbumSerializer(serializers.ModelSerializer): artist = ArtistSimpleSerializer(read_only=True) + cover = cover_field class Meta: model = models.Album @@ -156,6 +163,8 @@ class TagSerializer(serializers.ModelSerializer): class SimpleAlbumSerializer(serializers.ModelSerializer): + cover = cover_field + class Meta: model = models.Album fields = ("id", "mbid", "title", "release_date", "cover") diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index 71b8f315a..a60a34938 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -107,7 +107,7 @@ class PlaylistSerializer(serializers.ModelSerializer): covers = [] max_covers = 5 for plt in plts: - url = plt.track.album.cover.url + url = plt.track.album.cover.crop["200x200"].url if url in covers: continue covers.append(url) diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index a56406d8b..6cef3900b 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -11,12 +11,14 @@ import uuid from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from django.dispatch import receiver from django.urls import reverse from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ from versatileimagefield.fields import VersatileImageField +from versatileimagefield.image_warmer import VersatileImageFieldWarmer from funkwhale_api.common import fields, preferences from funkwhale_api.common import utils as common_utils @@ -205,3 +207,13 @@ class Invitation(models.Model): ) return super().save(**kwargs) + + +@receiver(models.signals.post_save, sender=User) +def warm_user_avatar(sender, instance, **kwargs): + if not instance.avatar: + return + user_avatar_warmer = VersatileImageFieldWarmer( + instance_or_queryset=instance, rendition_key_set="square", image_attr="avatar" + ) + num_created, failed_to_create = user_avatar_warmer.warm() diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index a13a44c81..74b060222 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -45,15 +45,7 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): return "Person" -avatar_field = VersatileImageFieldSerializer( - allow_null=True, - sizes=[ - ("original", "url"), - ("square_crop", "crop__400x400"), - ("medium_square_crop", "crop__200x200"), - ("small_square_crop", "crop__50x50"), - ], -) +avatar_field = VersatileImageFieldSerializer(allow_null=True, sizes="square") class UserBasicSerializer(serializers.ModelSerializer): diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 0d7400dfc..8705354f7 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -12,7 +12,12 @@ def test_artist_album_serializer(factories, to_api_date): "artist": album.artist.id, "creation_date": to_api_date(album.creation_date), "tracks_count": 1, - "cover": album.cover.url, + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, "release_date": to_api_date(album.release_date), } serializer = serializers.ArtistAlbumSerializer(album) @@ -83,7 +88,12 @@ def test_album_serializer(factories, to_api_date): "title": album.title, "artist": serializers.ArtistSimpleSerializer(album.artist).data, "creation_date": to_api_date(album.creation_date), - "cover": album.cover.url, + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, "release_date": to_api_date(album.release_date), "tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data, } diff --git a/api/tests/playlists/test_serializers.py b/api/tests/playlists/test_serializers.py index 42569f7a3..79765a24b 100644 --- a/api/tests/playlists/test_serializers.py +++ b/api/tests/playlists/test_serializers.py @@ -80,11 +80,11 @@ def test_playlist_serializer_include_covers(factories, api_request): qs = playlist.__class__.objects.with_covers().with_tracks_count() expected = [ - request.build_absolute_uri(t1.album.cover.url), - request.build_absolute_uri(t2.album.cover.url), - request.build_absolute_uri(t4.album.cover.url), - request.build_absolute_uri(t5.album.cover.url), - request.build_absolute_uri(t6.album.cover.url), + request.build_absolute_uri(t1.album.cover.crop["200x200"].url), + request.build_absolute_uri(t2.album.cover.crop["200x200"].url), + request.build_absolute_uri(t4.album.cover.crop["200x200"].url), + request.build_absolute_uri(t5.album.cover.crop["200x200"].url), + request.build_absolute_uri(t6.album.cover.crop["200x200"].url), ] serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request}) diff --git a/changes/changelog.d/image.enhancement b/changes/changelog.d/image.enhancement new file mode 100644 index 000000000..d0ad52c9f --- /dev/null +++ b/changes/changelog.d/image.enhancement @@ -0,0 +1,25 @@ +Use thumbnails for avatars and covers to reduce bandwidth + + +Image thumbnails [Manual action required] +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To reduce bandwidth usage on slow or limited connexions and improve performance +in general, we now use smaller images in the front-end. For instance, if you have +an album cover with a 1000x1000 pixel size, we will create smaller +versions of this image (50x50, 200x200, 400x400) and reference those resized version +when we don't actually need the original image. + +Thumbnail will be created automatically for new objects, however, you have +to launch a manual command to deal with existing ones. + +On docker setups:: + + docker-compose run --rm api python manage.py script create_image_variations --no-input + +On non-docker setups:: + + python manage.py script create_image_variations --no-input + +This should be quite fast but may take up to a few minutes depending on the number +of albums you have in database. It is safe to interrupt the process or rerun it multiple times. diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 6f744d74f..4a3f42c77 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -133,7 +133,7 @@ {{ index + 1}} - + diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 84b076208..704121d92 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -14,7 +14,7 @@
- +
diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 71f2ec80d..0c5a7c803 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -2,7 +2,7 @@
- +
diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index 37cddd500..fa71808b3 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -3,9 +3,9 @@

- + - +
@@ -13,7 +13,7 @@
-
+
@@ -92,8 +92,8 @@ export default { getImageStyle (album) { let url = '../../../assets/audio/default-cover.png' - if (album.cover) { - url = this.$store.getters['instance/absoluteUrl'](album.cover) + if (album.cover.original) { + url = this.$store.getters['instance/absoluteUrl'](album.cover.medium_square_crop) } else { return {} } diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index bd7cb68c4..dd32b4735 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -11,7 +11,7 @@ - + diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index ef3660ee2..cf79267cf 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -4,7 +4,7 @@ - + diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index 7c727b402..ca3ae2424 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -3,9 +3,11 @@

- + - + + +
@@ -13,7 +15,7 @@
- +
@@ -121,4 +123,7 @@ export default { left: 2.5em; } } +.refresh.icon { + float: right; +} diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 698f07ae2..312640baa 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -4,7 +4,7 @@