Skip to content
Snippets Groups Projects
Commit 27d54ee9 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'playlist-perfs' into 'develop'

Use smaller images when possible to increase performance on client-side

See merge request funkwhale/funkwhale!328
parents ba5c3bfc 979c554b
No related branches found
No related tags found
No related merge requests found
Showing
with 147 additions and 32 deletions
......@@ -471,3 +471,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}
......@@ -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:
......
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(
):
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,
......
......@@ -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()
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")
......
......@@ -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)
......
......@@ -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()
......@@ -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):
......
......@@ -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,
}
......
......@@ -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})
......
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 @@
<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">
......
......@@ -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">
......
......@@ -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">
......
......@@ -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 {}
}
......
......@@ -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">
......
......@@ -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">
......
......@@ -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>
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