Skip to content
Snippets Groups Projects
Verified Commit 979c554b authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Use cropped covers/avatars to reduce bandwidth use

parent 63df2e29
No related branches found
No related tags found
1 merge request!328Use smaller images when possible to increase performance on client-side
Pipeline #1611 passed with stages
in 4 minutes and 35 seconds
Showing
with 147 additions and 32 deletions
...@@ -467,3 +467,13 @@ MUSIC_DIRECTORY_SERVE_PATH = env( ...@@ -467,3 +467,13 @@ MUSIC_DIRECTORY_SERVE_PATH = env(
USERS_INVITATION_EXPIRATION_DAYS = env.int( USERS_INVITATION_EXPIRATION_DAYS = env.int(
"USERS_INVITATION_EXPIRATION_DAYS", default=14 "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}
...@@ -19,7 +19,7 @@ class Command(BaseCommand): ...@@ -19,7 +19,7 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
name = options["script_name"] name = options["script_name"]
if not name: if not name:
self.show_help() return self.show_help()
available_scripts = self.get_scripts() available_scripts = self.get_scripts()
try: try:
......
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"]
"""
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))
)
...@@ -18,7 +18,11 @@ class TrackFavoriteViewSet( ...@@ -18,7 +18,11 @@ class TrackFavoriteViewSet(
): ):
serializer_class = serializers.UserTrackFavoriteSerializer 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 = [ permission_classes = [
permissions.ConditionalAuthentication, permissions.ConditionalAuthentication,
permissions.OwnerPermission, permissions.OwnerPermission,
......
...@@ -15,7 +15,9 @@ from django.dispatch import receiver ...@@ -15,7 +15,9 @@ from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from versatileimagefield.fields import VersatileImageField from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from funkwhale_api import downloader, musicbrainz from funkwhale_api import downloader, musicbrainz
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
...@@ -641,3 +643,13 @@ def update_request_status(sender, instance, created, **kwargs): ...@@ -641,3 +643,13 @@ def update_request_status(sender, instance, created, **kwargs):
# let's mark the request as imported since the import is over # let's mark the request as imported since the import is over
instance.import_request.status = "imported" instance.import_request.status = "imported"
return instance.import_request.save(update_fields=["status"]) 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()
from django.db.models import Q from django.db.models import Q
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag from taggit.models import Tag
from versatileimagefield.serializers import VersatileImageFieldSerializer
from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.users.serializers import UserBasicSerializer from funkwhale_api.users.serializers import UserBasicSerializer
...@@ -8,8 +9,12 @@ from funkwhale_api.users.serializers import UserBasicSerializer ...@@ -8,8 +9,12 @@ from funkwhale_api.users.serializers import UserBasicSerializer
from . import models, tasks from . import models, tasks
cover_field = VersatileImageFieldSerializer(allow_null=True, sizes="square")
class ArtistAlbumSerializer(serializers.ModelSerializer): class ArtistAlbumSerializer(serializers.ModelSerializer):
tracks_count = serializers.SerializerMethodField() tracks_count = serializers.SerializerMethodField()
cover = cover_field
class Meta: class Meta:
model = models.Album model = models.Album
...@@ -87,6 +92,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer): ...@@ -87,6 +92,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
class AlbumSerializer(serializers.ModelSerializer): class AlbumSerializer(serializers.ModelSerializer):
tracks = serializers.SerializerMethodField() tracks = serializers.SerializerMethodField()
artist = ArtistSimpleSerializer(read_only=True) artist = ArtistSimpleSerializer(read_only=True)
cover = cover_field
class Meta: class Meta:
model = models.Album model = models.Album
...@@ -111,6 +117,7 @@ class AlbumSerializer(serializers.ModelSerializer): ...@@ -111,6 +117,7 @@ class AlbumSerializer(serializers.ModelSerializer):
class TrackAlbumSerializer(serializers.ModelSerializer): class TrackAlbumSerializer(serializers.ModelSerializer):
artist = ArtistSimpleSerializer(read_only=True) artist = ArtistSimpleSerializer(read_only=True)
cover = cover_field
class Meta: class Meta:
model = models.Album model = models.Album
...@@ -156,6 +163,8 @@ class TagSerializer(serializers.ModelSerializer): ...@@ -156,6 +163,8 @@ class TagSerializer(serializers.ModelSerializer):
class SimpleAlbumSerializer(serializers.ModelSerializer): class SimpleAlbumSerializer(serializers.ModelSerializer):
cover = cover_field
class Meta: class Meta:
model = models.Album model = models.Album
fields = ("id", "mbid", "title", "release_date", "cover") fields = ("id", "mbid", "title", "release_date", "cover")
......
...@@ -107,7 +107,7 @@ class PlaylistSerializer(serializers.ModelSerializer): ...@@ -107,7 +107,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
covers = [] covers = []
max_covers = 5 max_covers = 5
for plt in plts: for plt in plts:
url = plt.track.album.cover.url url = plt.track.album.cover.crop["200x200"].url
if url in covers: if url in covers:
continue continue
covers.append(url) covers.append(url)
......
...@@ -11,12 +11,14 @@ import uuid ...@@ -11,12 +11,14 @@ import uuid
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from versatileimagefield.fields import VersatileImageField from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from funkwhale_api.common import fields, preferences from funkwhale_api.common import fields, preferences
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
...@@ -205,3 +207,13 @@ class Invitation(models.Model): ...@@ -205,3 +207,13 @@ class Invitation(models.Model):
) )
return super().save(**kwargs) 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()
...@@ -45,15 +45,7 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): ...@@ -45,15 +45,7 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
return "Person" return "Person"
avatar_field = VersatileImageFieldSerializer( avatar_field = VersatileImageFieldSerializer(allow_null=True, sizes="square")
allow_null=True,
sizes=[
("original", "url"),
("square_crop", "crop__400x400"),
("medium_square_crop", "crop__200x200"),
("small_square_crop", "crop__50x50"),
],
)
class UserBasicSerializer(serializers.ModelSerializer): class UserBasicSerializer(serializers.ModelSerializer):
......
...@@ -12,7 +12,12 @@ def test_artist_album_serializer(factories, to_api_date): ...@@ -12,7 +12,12 @@ def test_artist_album_serializer(factories, to_api_date):
"artist": album.artist.id, "artist": album.artist.id,
"creation_date": to_api_date(album.creation_date), "creation_date": to_api_date(album.creation_date),
"tracks_count": 1, "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), "release_date": to_api_date(album.release_date),
} }
serializer = serializers.ArtistAlbumSerializer(album) serializer = serializers.ArtistAlbumSerializer(album)
...@@ -83,7 +88,12 @@ def test_album_serializer(factories, to_api_date): ...@@ -83,7 +88,12 @@ def test_album_serializer(factories, to_api_date):
"title": album.title, "title": album.title,
"artist": serializers.ArtistSimpleSerializer(album.artist).data, "artist": serializers.ArtistSimpleSerializer(album.artist).data,
"creation_date": to_api_date(album.creation_date), "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), "release_date": to_api_date(album.release_date),
"tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data, "tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data,
} }
......
...@@ -80,11 +80,11 @@ def test_playlist_serializer_include_covers(factories, api_request): ...@@ -80,11 +80,11 @@ def test_playlist_serializer_include_covers(factories, api_request):
qs = playlist.__class__.objects.with_covers().with_tracks_count() qs = playlist.__class__.objects.with_covers().with_tracks_count()
expected = [ expected = [
request.build_absolute_uri(t1.album.cover.url), request.build_absolute_uri(t1.album.cover.crop["200x200"].url),
request.build_absolute_uri(t2.album.cover.url), request.build_absolute_uri(t2.album.cover.crop["200x200"].url),
request.build_absolute_uri(t4.album.cover.url), request.build_absolute_uri(t4.album.cover.crop["200x200"].url),
request.build_absolute_uri(t5.album.cover.url), request.build_absolute_uri(t5.album.cover.crop["200x200"].url),
request.build_absolute_uri(t6.album.cover.url), request.build_absolute_uri(t6.album.cover.crop["200x200"].url),
] ]
serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request}) serializer = serializers.PlaylistSerializer(qs.get(), context={"request": request})
......
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.
...@@ -133,7 +133,7 @@ ...@@ -133,7 +133,7 @@
<tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in tracks" :key="index" :class="[{'active': index === queue.currentIndex}]"> <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
<td class="right aligned">{{ index + 1}}</td> <td class="right aligned">{{ index + 1}}</td>
<td class="center aligned"> <td class="center aligned">
<img class="ui mini image" v-if="track.album.cover" :src="$store.getters['instance/absoluteUrl'](track.album.cover)"> <img class="ui mini image" v-if="track.album.cover && track.album.cover.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
<img class="ui mini image" v-else src="../assets/audio/default-cover.png"> <img class="ui mini image" v-else src="../assets/audio/default-cover.png">
</td> </td>
<td colspan="4"> <td colspan="4">
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
<div v-if="currentTrack" class="track-area ui unstackable items"> <div v-if="currentTrack" class="track-area ui unstackable items">
<div class="ui inverted item"> <div class="ui inverted item">
<div class="ui tiny image"> <div class="ui tiny image">
<img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover)"> <img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover && currentTrack.album.cover.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.medium_square_crop)">
<img v-else src="../../assets/audio/default-cover.png"> <img v-else src="../../assets/audio/default-cover.png">
</div> </div>
<div class="middle aligned content"> <div class="middle aligned content">
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="ui card"> <div class="ui card">
<div class="content"> <div class="content">
<div class="right floated tiny ui image"> <div class="right floated tiny ui image">
<img v-if="album.cover" v-lazy="$store.getters['instance/absoluteUrl'](album.cover)"> <img v-if="album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](album.cover.square_crop)">
<img v-else src="../../../assets/audio/default-cover.png"> <img v-else src="../../../assets/audio/default-cover.png">
</div> </div>
<div class="header"> <div class="header">
......
...@@ -3,9 +3,9 @@ ...@@ -3,9 +3,9 @@
<h3 class="ui header"> <h3 class="ui header">
<slot name="title"></slot> <slot name="title"></slot>
</h3> </h3>
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle left', 'icon']"> <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'medium', 'angle left', 'icon']">
</i> </i>
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle right', 'icon']"> <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'medium', 'angle right', 'icon']">
</i> </i>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="ui five cards"> <div class="ui five cards">
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
<div class="ui loader"></div> <div class="ui loader"></div>
</div> </div>
<div class="card" v-for="album in albums" :key="album.id"> <div class="card" v-for="album in albums" :key="album.id">
<div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover}]" :style="getImageStyle(album)"> <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover.original}]" :style="getImageStyle(album)">
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button> <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button>
</div> </div>
<div class="content"> <div class="content">
...@@ -92,8 +92,8 @@ export default { ...@@ -92,8 +92,8 @@ export default {
getImageStyle (album) { getImageStyle (album) {
let url = '../../../assets/audio/default-cover.png' let url = '../../../assets/audio/default-cover.png'
if (album.cover) { if (album.cover.original) {
url = this.$store.getters['instance/absoluteUrl'](album.cover) url = this.$store.getters['instance/absoluteUrl'](album.cover.medium_square_crop)
} else { } else {
return {} return {}
} }
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<tbody> <tbody>
<tr v-for="album in albums"> <tr v-for="album in albums">
<td> <td>
<img class="ui mini image" v-if="album.cover" :src="$store.getters['instance/absoluteUrl'](album.cover)"> <img class="ui mini image" v-if="album.cover.original" :src="$store.getters['instance/absoluteUrl'](album.cover.small_square_crop)">
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
</td> </td>
<td colspan="4"> <td colspan="4">
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<play-button class="basic icon" :discrete="true" :track="track"></play-button> <play-button class="basic icon" :discrete="true" :track="track"></play-button>
</td> </td>
<td> <td>
<img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)"> <img class="ui mini image" v-if="track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.small_square_crop)">
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png"> <img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
</td> </td>
<td colspan="6"> <td colspan="6">
......
...@@ -3,9 +3,11 @@ ...@@ -3,9 +3,11 @@
<h3 class="ui header"> <h3 class="ui header">
<slot name="title"></slot> <slot name="title"></slot>
</h3> </h3>
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']"> <i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'medium', 'angle up', 'icon']">
</i> </i>
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']"> <i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'medium', 'angle down', 'icon']">
</i>
<i @click="fetchData(url)" :class="['ui', 'circular', 'medium', 'refresh', 'icon']">
</i> </i>
<div class="ui divided unstackable items"> <div class="ui divided unstackable items">
<div v-if="isLoading" class="ui inverted active dimmer"> <div v-if="isLoading" class="ui inverted active dimmer">
...@@ -13,7 +15,7 @@ ...@@ -13,7 +15,7 @@
</div> </div>
<div class="item" v-for="object in objects" :key="object.id"> <div class="item" v-for="object in objects" :key="object.id">
<div class="ui tiny image"> <div class="ui tiny image">
<img v-if="object.track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover)"> <img v-if="object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
<img v-else src="../../../assets/audio/default-cover.png"> <img v-else src="../../../assets/audio/default-cover.png">
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button> <play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button>
</div> </div>
...@@ -121,4 +123,7 @@ export default { ...@@ -121,4 +123,7 @@ export default {
left: 2.5em; left: 2.5em;
} }
} }
.refresh.icon {
float: right;
}
</style> </style>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment