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

View File

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

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

View File

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

View File

@ -1,6 +1,7 @@
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
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):
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):
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):
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")

View File

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

View File

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

View File

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

View File

@ -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):
"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,
} }

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 @@
</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 {
left: 2.5em; left: 2.5em;
} }
} }
.refresh.icon {
float: right;
}
</style> </style>

View File

@ -4,7 +4,7 @@
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div> </div>
<template v-if="album"> <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"> <div class="segment-content">
<h2 class="ui center aligned icon header"> <h2 class="ui center aligned icon header">
<i class="circular inverted sound yellow icon"></i> <i class="circular inverted sound yellow icon"></i>
@ -98,10 +98,10 @@ export default {
return 'https://musicbrainz.org/release/' + this.album.mbid return 'https://musicbrainz.org/release/' + this.album.mbid
}, },
headerStyle () { headerStyle () {
if (!this.album.cover) { if (!this.album.cover.original) {
return '' 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: { watch: {

View File

@ -158,10 +158,10 @@ export default {
})[0] })[0]
}, },
headerStyle () { headerStyle () {
if (!this.cover) { if (!this.cover.original) {
return '' 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: { watch: {

View File

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