Use cropped covers/avatars to reduce bandwidth use

This commit is contained in:
Eliot Berriot 2018-07-18 15:37:07 +02:00
parent 63df2e29cb
commit 979c554b4a
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
23 changed files with 161 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -133,7 +133,7 @@
<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="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">
</td>
<td colspan="4">

View File

@ -14,7 +14,7 @@
<div v-if="currentTrack" class="track-area ui unstackable items">
<div class="ui inverted item">
<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">
</div>
<div class="middle aligned content">

View File

@ -2,7 +2,7 @@
<div class="ui card">
<div class="content">
<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">
</div>
<div class="header">

View File

@ -3,9 +3,9 @@
<h3 class="ui header">
<slot name="title"></slot>
</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 @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>
<div class="ui hidden divider"></div>
<div class="ui five cards">
@ -13,7 +13,7 @@
<div class="ui loader"></div>
</div>
<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>
</div>
<div class="content">
@ -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 {}
}

View File

@ -11,7 +11,7 @@
<tbody>
<tr v-for="album in albums">
<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">
</td>
<td colspan="4">

View File

@ -4,7 +4,7 @@
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
</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">
</td>
<td colspan="6">

View File

@ -3,9 +3,11 @@
<h3 class="ui header">
<slot name="title"></slot>
</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 @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>
<div class="ui divided unstackable items">
<div v-if="isLoading" class="ui inverted active dimmer">
@ -13,7 +15,7 @@
</div>
<div class="item" v-for="object in objects" :key="object.id">
<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">
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'orange', 'icon', 'button']" :track="object.track"></play-button>
</div>
@ -121,4 +123,7 @@ export default {
left: 2.5em;
}
}
.refresh.icon {
float: right;
}
</style>

View File

@ -4,7 +4,7 @@
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="album">
<div :class="['ui', 'head', {'with-background': album.cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title">
<div :class="['ui', 'head', {'with-background': album.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="album.title">
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted sound yellow icon"></i>
@ -98,10 +98,10 @@ export default {
return 'https://musicbrainz.org/release/' + this.album.mbid
},
headerStyle () {
if (!this.album.cover) {
if (!this.album.cover.original) {
return ''
}
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover) + ')'
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover.original) + ')'
}
},
watch: {

View File

@ -158,10 +158,10 @@ export default {
})[0]
},
headerStyle () {
if (!this.cover) {
if (!this.cover.original) {
return ''
}
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')'
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover.original) + ')'
}
},
watch: {

View File

@ -3,9 +3,11 @@
<h3 class="ui header">
<slot name="title"></slot>
</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 @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>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
@ -75,3 +77,8 @@ export default {
}
}
</script>
<style scoped>
.refresh.icon {
float: right;
}
</style>