Use cropped covers/avatars to reduce bandwidth use
This commit is contained in:
parent
63df2e29cb
commit
979c554b4a
|
@ -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}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"]
|
|
@ -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))
|
||||
)
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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.
|
|
@ -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>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue