diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 4759d7aab2b51b76281782498b47e510bda168c2..a445e102cfc5ef07487379243cf089db0217d0c6 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -412,7 +412,12 @@ CELERY_BEAT_SCHEDULE = { "task": "federation.clean_music_cache", "schedule": crontab(hour="*/2"), "options": {"expires": 60 * 2}, - } + }, + "music.clean_transcoding_cache": { + "task": "music.clean_transcoding_cache", + "schedule": crontab(hour="*"), + "options": {"expires": 60 * 2}, + }, } JWT_AUTH = { diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py index 5fc6f6804ca4c5e5289fb224c6e9adce503be978..70aecd632f6e77109dc3e8d51e06695acd19d6cf 100644 --- a/api/funkwhale_api/common/search.py +++ b/api/funkwhale_api/common/search.py @@ -3,7 +3,7 @@ import re from django.db.models import Q -QUERY_REGEX = re.compile('(((?P<key>\w+):)?(?P<value>"[^"]+"|[\S]+))') +QUERY_REGEX = re.compile(r'(((?P<key>\w+):)?(?P<value>"[^"]+"|[\S]+))') def parse_query(query): diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py index 7e30c28a6a7283e0c57d049961e617fb60aec3c4..5f4e1cd42e34393ecf8545c62278a2097cc56deb 100644 --- a/api/funkwhale_api/favorites/views.py +++ b/api/funkwhale_api/favorites/views.py @@ -51,7 +51,7 @@ class TrackFavoriteViewSet( queryset = queryset.filter( fields.privacy_level_query(self.request.user, "user__privacy_level") ) - tracks = Track.objects.annotate_playable_by_actor( + tracks = Track.objects.with_playable_uploads( music_utils.get_actor_from_request(self.request) ).select_related("artist", "album__artist") queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index ec3b4f3c080fabe174a340e3326b7365668f9c9e..56c30af36511e8cf124645dfcd00f835e3a04287 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -41,7 +41,7 @@ class ListeningViewSet( queryset = queryset.filter( fields.privacy_level_query(self.request.user, "user__privacy_level") ) - tracks = Track.objects.annotate_playable_by_actor( + tracks = Track.objects.with_playable_uploads( music_utils.get_actor_from_request(self.request) ).select_related("artist", "album__artist") return queryset.prefetch_related(Prefetch("track", queryset=tracks)) diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py index 8f9768857bbae298eb5587e526cc99a7c9f6e02d..fca544cc84a529d3cd3a468c1bec6858697427dc 100644 --- a/api/funkwhale_api/music/admin.py +++ b/api/funkwhale_api/music/admin.py @@ -78,6 +78,28 @@ class UploadAdmin(admin.ModelAdmin): list_filter = ["mimetype", "import_status", "library__privacy_level"] +@admin.register(models.UploadVersion) +class UploadVersionAdmin(admin.ModelAdmin): + list_display = [ + "upload", + "audio_file", + "mimetype", + "size", + "bitrate", + "creation_date", + "accessed_date", + ] + list_select_related = ["upload"] + search_fields = [ + "upload__source", + "upload__acoustid_track_id", + "upload__track__title", + "upload__track__album__title", + "upload__track__artist__name", + ] + list_filter = ["mimetype"] + + def launch_scan(modeladmin, request, queryset): for library in queryset: library.schedule_scan(actor=request.user.actor, force=True) diff --git a/api/funkwhale_api/music/dynamic_preferences_registry.py b/api/funkwhale_api/music/dynamic_preferences_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..c46af502217ac39dce8d83c8923e023adf53c901 --- /dev/null +++ b/api/funkwhale_api/music/dynamic_preferences_registry.py @@ -0,0 +1,34 @@ +from dynamic_preferences import types +from dynamic_preferences.registries import global_preferences_registry + +music = types.Section("music") + + +@global_preferences_registry.register +class MaxTracks(types.BooleanPreference): + show_in_api = True + section = music + name = "transcoding_enabled" + verbose_name = "Transcoding enabled" + help_text = ( + "Enable transcoding of audio files in formats requested by the client. " + "This is especially useful for devices that do not support formats " + "such as Flac or Ogg, but the transcoding process will increase the " + "load on the server." + ) + default = True + + +@global_preferences_registry.register +class MusicCacheDuration(types.IntPreference): + show_in_api = True + section = music + name = "transcoding_cache_duration" + default = 60 * 24 * 7 + verbose_name = "Transcoding cache duration" + help_text = ( + "How much minutes do you want to keep a copy of transcoded tracks " + "on the server? Transcoded files that were not listened in this interval " + "will be erased and retranscoded on the next listening." + ) + field_kwargs = {"required": False} diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index 9571f978516535b357665a6672235be8abbd3b47..0ec3fdb227a61ab77c88d994fb3a66e495495554 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -95,6 +95,18 @@ class UploadFactory(factory.django.DjangoModelFactory): ) +@registry.register +class UploadVersionFactory(factory.django.DjangoModelFactory): + upload = factory.SubFactory(UploadFactory, bitrate=200000) + bitrate = factory.SelfAttribute("upload.bitrate") + mimetype = "audio/mpeg" + audio_file = factory.django.FileField() + size = 2000000 + + class Meta: + model = "music.UploadVersion" + + @registry.register class WorkFactory(factory.django.DjangoModelFactory): mbid = factory.Faker("uuid4") diff --git a/api/funkwhale_api/music/migrations/0033_auto_20181023_1837.py b/api/funkwhale_api/music/migrations/0033_auto_20181023_1837.py new file mode 100644 index 0000000000000000000000000000000000000000..003349b53caf8ae0766a10f283b8814f00d9e67c --- /dev/null +++ b/api/funkwhale_api/music/migrations/0033_auto_20181023_1837.py @@ -0,0 +1,53 @@ +# Generated by Django 2.0.9 on 2018-10-23 18:37 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import funkwhale_api.music.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0032_track_file_to_upload'), + ] + + operations = [ + migrations.CreateModel( + name='UploadVersion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mimetype', models.CharField(choices=[('audio/ogg', 'ogg'), ('audio/mpeg', 'mp3'), ('audio/x-flac', 'flac')], max_length=50)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('accessed_date', models.DateTimeField(blank=True, null=True)), + ('audio_file', models.FileField(max_length=255, upload_to=funkwhale_api.music.models.get_file_path)), + ('bitrate', models.PositiveIntegerField()), + ('size', models.IntegerField()), + ('upload', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='music.Upload')), + ], + ), + migrations.AlterField( + model_name='album', + name='from_activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='federation.Activity'), + ), + migrations.AlterField( + model_name='artist', + name='from_activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='federation.Activity'), + ), + migrations.AlterField( + model_name='track', + name='from_activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='federation.Activity'), + ), + migrations.AlterField( + model_name='work', + name='from_activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='federation.Activity'), + ), + migrations.AlterUniqueTogether( + name='uploadversion', + unique_together={('upload', 'mimetype', 'bitrate')}, + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 87f7ba8191c62547c4ebeb4c7f41a3986ef23a77..a49b55236b0477ecec92d00d9e1466a7c954539b 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -11,7 +11,7 @@ from django.conf import settings from django.contrib.postgres.fields import JSONField from django.core.files.base import ContentFile from django.core.serializers.json import DjangoJSONEncoder -from django.db import models +from django.db import models, transaction from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse @@ -124,8 +124,8 @@ class ArtistQuerySet(models.QuerySet): def annotate_playable_by_actor(self, actor): tracks = ( - Track.objects.playable_by(actor) - .filter(artist=models.OuterRef("id")) + Upload.objects.playable_by(actor) + .filter(track__artist=models.OuterRef("id")) .order_by("id") .values("id")[:1] ) @@ -192,8 +192,8 @@ class AlbumQuerySet(models.QuerySet): def annotate_playable_by_actor(self, actor): tracks = ( - Track.objects.playable_by(actor) - .filter(album=models.OuterRef("id")) + Upload.objects.playable_by(actor) + .filter(track__album=models.OuterRef("id")) .order_by("id") .values("id")[:1] ) @@ -207,6 +207,10 @@ class AlbumQuerySet(models.QuerySet): else: return self.exclude(tracks__in=tracks).distinct() + def with_prefetched_tracks_and_playable_uploads(self, actor): + tracks = Track.objects.with_playable_uploads(actor) + return self.prefetch_related(models.Prefetch("tracks", queryset=tracks)) + class Album(APIModelMixin): title = models.CharField(max_length=255) @@ -403,18 +407,10 @@ class TrackQuerySet(models.QuerySet): else: return self.exclude(uploads__in=files).distinct() - def annotate_duration(self): - first_upload = Upload.objects.filter(track=models.OuterRef("pk")).order_by("pk") - return self.annotate( - duration=models.Subquery(first_upload.values("duration")[:1]) - ) - - def annotate_file_data(self): - first_upload = Upload.objects.filter(track=models.OuterRef("pk")).order_by("pk") - return self.annotate( - bitrate=models.Subquery(first_upload.values("bitrate")[:1]), - size=models.Subquery(first_upload.values("size")[:1]), - mimetype=models.Subquery(first_upload.values("mimetype")[:1]), + def with_playable_uploads(self, actor): + uploads = Upload.objects.playable_by(actor).select_related("track") + return self.prefetch_related( + models.Prefetch("uploads", queryset=uploads, to_attr="playable_uploads") ) @@ -578,6 +574,9 @@ TRACK_FILE_IMPORT_STATUS_CHOICES = ( def get_file_path(instance, filename): + if isinstance(instance, UploadVersion): + return common_utils.ChunkedPath("transcoded")(instance, filename) + if instance.library.actor.get_user(): return common_utils.ChunkedPath("tracks")(instance, filename) else: @@ -741,6 +740,61 @@ class Upload(models.Model): def listen_url(self): return self.track.listen_url + "?upload={}".format(self.uuid) + def get_transcoded_version(self, format): + mimetype = utils.EXTENSION_TO_MIMETYPE[format] + existing_versions = list(self.versions.filter(mimetype=mimetype)) + if existing_versions: + # we found an existing version, no need to transcode again + return existing_versions[0] + + return self.create_transcoded_version(mimetype, format) + + @transaction.atomic + def create_transcoded_version(self, mimetype, format): + # we create the version with an empty file, then + # we'll write to it + f = ContentFile(b"") + version = self.versions.create( + mimetype=mimetype, bitrate=self.bitrate or 128000, size=0 + ) + # we keep the same name, but we update the extension + new_name = os.path.splitext(os.path.basename(self.audio_file.name))[ + 0 + ] + ".{}".format(format) + version.audio_file.save(new_name, f) + utils.transcode_file( + input=self.audio_file, + output=version.audio_file, + input_format=utils.MIMETYPE_TO_EXTENSION[self.mimetype], + output_format=utils.MIMETYPE_TO_EXTENSION[mimetype], + ) + version.size = version.audio_file.size + version.save(update_fields=["size"]) + + return version + + +MIMETYPE_CHOICES = [(mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE] + + +class UploadVersion(models.Model): + upload = models.ForeignKey( + Upload, related_name="versions", on_delete=models.CASCADE + ) + mimetype = models.CharField(max_length=50, choices=MIMETYPE_CHOICES) + creation_date = models.DateTimeField(default=timezone.now) + accessed_date = models.DateTimeField(null=True, blank=True) + audio_file = models.FileField(upload_to=get_file_path, max_length=255) + bitrate = models.PositiveIntegerField() + size = models.IntegerField() + + class Meta: + unique_together = ("upload", "mimetype", "bitrate") + + @property + def filename(self): + return self.upload.filename + IMPORT_STATUS_CHOICES = ( ("pending", "Pending"), diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index a23fc1daadf6d7bfbeb4b7d9d625475fcd6a1ad1..5d1dab00696fef6614c73371a4419c42cfbf2b6e 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -59,7 +59,7 @@ class ArtistSimpleSerializer(serializers.ModelSerializer): class AlbumTrackSerializer(serializers.ModelSerializer): artist = ArtistSimpleSerializer(read_only=True) - is_playable = serializers.SerializerMethodField() + uploads = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField() duration = serializers.SerializerMethodField() @@ -73,16 +73,14 @@ class AlbumTrackSerializer(serializers.ModelSerializer): "artist", "creation_date", "position", - "is_playable", + "uploads", "listen_url", "duration", ) - def get_is_playable(self, obj): - try: - return bool(obj.is_playable_by_actor) - except AttributeError: - return None + def get_uploads(self, obj): + uploads = getattr(obj, "playable_uploads", []) + return TrackUploadSerializer(uploads, many=True).data def get_listen_url(self, obj): return obj.listen_url @@ -123,7 +121,9 @@ class AlbumSerializer(serializers.ModelSerializer): def get_is_playable(self, obj): try: - return any([bool(t.is_playable_by_actor) for t in obj.tracks.all()]) + return any( + [bool(getattr(t, "playable_uploads", [])) for t in obj.tracks.all()] + ) except AttributeError: return None @@ -145,16 +145,26 @@ class TrackAlbumSerializer(serializers.ModelSerializer): ) +class TrackUploadSerializer(serializers.ModelSerializer): + class Meta: + model = models.Upload + fields = ( + "uuid", + "listen_url", + "size", + "duration", + "bitrate", + "mimetype", + "extension", + ) + + class TrackSerializer(serializers.ModelSerializer): artist = ArtistSimpleSerializer(read_only=True) album = TrackAlbumSerializer(read_only=True) lyrics = serializers.SerializerMethodField() - is_playable = serializers.SerializerMethodField() + uploads = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField() - duration = serializers.SerializerMethodField() - bitrate = serializers.SerializerMethodField() - size = serializers.SerializerMethodField() - mimetype = serializers.SerializerMethodField() class Meta: model = models.Track @@ -167,12 +177,8 @@ class TrackSerializer(serializers.ModelSerializer): "creation_date", "position", "lyrics", - "is_playable", + "uploads", "listen_url", - "duration", - "bitrate", - "size", - "mimetype", ) def get_lyrics(self, obj): @@ -181,35 +187,9 @@ class TrackSerializer(serializers.ModelSerializer): def get_listen_url(self, obj): return obj.listen_url - def get_is_playable(self, obj): - try: - return bool(obj.is_playable_by_actor) - except AttributeError: - return None - - def get_duration(self, obj): - try: - return obj.duration - except AttributeError: - return None - - def get_bitrate(self, obj): - try: - return obj.bitrate - except AttributeError: - return None - - def get_size(self, obj): - try: - return obj.size - except AttributeError: - return None - - def get_mimetype(self, obj): - try: - return obj.mimetype - except AttributeError: - return None + def get_uploads(self, obj): + uploads = getattr(obj, "playable_uploads", []) + return TrackUploadSerializer(uploads, many=True).data class LibraryForOwnerSerializer(serializers.ModelSerializer): diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index d96471b961c5fcfe0efa5bf1809f0e93f7106fb8..7008b12fc51ebc094b69876f4823ee1ab5c94261 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -1,4 +1,5 @@ import collections +import datetime import logging import os @@ -10,7 +11,7 @@ from django.dispatch import receiver from musicbrainzngs import ResponseError from requests.exceptions import RequestException -from funkwhale_api.common import channels +from funkwhale_api.common import channels, preferences from funkwhale_api.federation import routes from funkwhale_api.federation import library as lb from funkwhale_api.taskapp import celery @@ -526,3 +527,19 @@ def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kw }, }, ) + + +@celery.app.task(name="music.clean_transcoding_cache") +def clean_transcoding_cache(): + delay = preferences.get("music__transcoding_cache_duration") + if delay < 1: + return # cache clearing disabled + limit = timezone.now() - datetime.timedelta(minutes=delay) + candidates = ( + models.UploadVersion.objects.filter( + (Q(accessed_date__lt=limit) | Q(accessed_date=None)) + ) + .only("audio_file", "id") + .order_by("id") + ) + return candidates.delete() diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 6da9ad9493647ad398a35e27faa8f7d04fb0eb7f..2c1210cf78c7cddeaba1968f0557d4f48c048302 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -2,6 +2,7 @@ import mimetypes import magic import mutagen +import pydub from funkwhale_api.common.search import normalize_query, get_query # noqa @@ -68,3 +69,10 @@ def get_actor_from_request(request): actor = request.user.actor return actor + + +def transcode_file(input, output, input_format, output_format, **kwargs): + with input.open("rb"): + audio = pydub.AudioSegment.from_file(input, format=input_format) + with output.open("wb"): + return audio.export(output, format=output_format, **kwargs) diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index c8d1b94fc39a9b0c98f559b2d0b098c35515cd09..744fd43dd83a48ae236a81a9f7e278cc61758f61 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -15,8 +15,9 @@ from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from taggit.models import Tag -from funkwhale_api.common import utils as common_utils from funkwhale_api.common import permissions as common_permissions +from funkwhale_api.common import preferences +from funkwhale_api.common import utils as common_utils from funkwhale_api.federation.authentication import SignatureAuthentication from funkwhale_api.federation import api_serializers as federation_api_serializers from funkwhale_api.federation import routes @@ -92,17 +93,9 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): queryset = super().get_queryset() - tracks = models.Track.objects.annotate_playable_by_actor( + tracks = models.Track.objects.select_related("artist").with_playable_uploads( utils.get_actor_from_request(self.request) - ).select_related("artist") - if ( - hasattr(self, "kwargs") - and self.kwargs - and self.request.method.lower() == "get" - ): - # we are detailing a single album, so we can add the overhead - # to fetch additional data - tracks = tracks.annotate_duration() + ) qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks)) return qs.distinct() @@ -193,18 +186,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): if user.is_authenticated and filter_favorites == "true": queryset = queryset.filter(track_favorites__user=user) - queryset = queryset.annotate_playable_by_actor( + queryset = queryset.with_playable_uploads( utils.get_actor_from_request(self.request) - ).annotate_duration() - if ( - hasattr(self, "kwargs") - and self.kwargs - and self.request.method.lower() == "get" - ): - # we are detailing a single track, so we can add the overhead - # to fetch additional data - queryset = queryset.annotate_file_data() - return queryset.distinct() + ) + return queryset @detail_route(methods=["get"]) @transaction.non_atomic_requests @@ -267,12 +252,31 @@ def get_file_path(audio_file): return path.encode("utf-8") -def handle_serve(upload, user): +def should_transcode(upload, format): + if not preferences.get("music__transcoding_enabled"): + return False + if format is None: + return False + if format not in utils.EXTENSION_TO_MIMETYPE: + # format should match supported formats + return False + if upload.mimetype is None: + # upload should have a mimetype, otherwise we cannot transcode + return False + if upload.mimetype == utils.EXTENSION_TO_MIMETYPE[format]: + # requested format sould be different than upload mimetype, otherwise + # there is no need to transcode + return False + return True + + +def handle_serve(upload, user, format=None): f = upload # we update the accessed_date - f.accessed_date = timezone.now() - f.save(update_fields=["accessed_date"]) - + now = timezone.now() + upload.accessed_date = now + upload.save(update_fields=["accessed_date"]) + f = upload if f.audio_file: file_path = get_file_path(f.audio_file) @@ -298,6 +302,14 @@ def handle_serve(upload, user): elif f.source and f.source.startswith("file://"): file_path = get_file_path(f.source.replace("file://", "", 1)) mt = f.mimetype + + if should_transcode(f, format): + transcoded_version = upload.get_transcoded_version(format) + transcoded_version.accessed_date = now + transcoded_version.save(update_fields=["accessed_date"]) + f = transcoded_version + file_path = get_file_path(f.audio_file) + mt = f.mimetype if mt: response = Response(content_type=mt) else: @@ -337,7 +349,8 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): if not upload: return Response(status=404) - return handle_serve(upload, user=request.user) + format = request.GET.get("to") + return handle_serve(upload, user=request.user, format=format) class UploadViewSet( diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py index e1895137d3630ec9970862ce3c85bbfc0472a0ee..1d33388015a80c618628b4923311cab49f15298c 100644 --- a/api/funkwhale_api/playlists/models.py +++ b/api/funkwhale_api/playlists/models.py @@ -38,15 +38,14 @@ class PlaylistQuerySet(models.QuerySet): ) return self.prefetch_related(plt_prefetch) - def annotate_playable_by_actor(self, actor): - plts = ( - PlaylistTrack.objects.playable_by(actor) - .filter(playlist=models.OuterRef("id")) - .order_by("id") - .values("id")[:1] + def with_playable_plts(self, actor): + return self.prefetch_related( + models.Prefetch( + "playlist_tracks", + queryset=PlaylistTrack.objects.playable_by(actor), + to_attr="playable_plts", + ) ) - subquery = models.Subquery(plts) - return self.annotate(is_playable_by_actor=subquery) def playable_by(self, actor, include=True): plts = PlaylistTrack.objects.playable_by(actor, include) @@ -148,7 +147,7 @@ class Playlist(models.Model): class PlaylistTrackQuerySet(models.QuerySet): def for_nested_serialization(self, actor=None): - tracks = music_models.Track.objects.annotate_playable_by_actor(actor) + tracks = music_models.Track.objects.with_playable_uploads(actor) tracks = tracks.select_related("artist", "album__artist") return self.prefetch_related( models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track") @@ -156,8 +155,8 @@ class PlaylistTrackQuerySet(models.QuerySet): def annotate_playable_by_actor(self, actor): tracks = ( - music_models.Track.objects.playable_by(actor) - .filter(pk=models.OuterRef("track")) + music_models.Upload.objects.playable_by(actor) + .filter(track__pk=models.OuterRef("track")) .order_by("id") .values("id")[:1] ) diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py index c1ca84e15be2708b6b328f582eadedd3aed08ce1..b64996640259c03a6394c1353bea641afb31dcf5 100644 --- a/api/funkwhale_api/playlists/serializers.py +++ b/api/funkwhale_api/playlists/serializers.py @@ -93,7 +93,7 @@ class PlaylistSerializer(serializers.ModelSerializer): def get_is_playable(self, obj): try: - return bool(obj.is_playable_by_actor) + return bool(obj.playable_plts) except AttributeError: return None diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py index 4934b92a019529542702796d4de71d40144885f8..6ff49173c9413bd9c82f7575dd5d1533b1d63489 100644 --- a/api/funkwhale_api/playlists/views.py +++ b/api/funkwhale_api/playlists/views.py @@ -78,7 +78,7 @@ class PlaylistViewSet( def get_queryset(self): return self.queryset.filter( fields.privacy_level_query(self.request.user) - ).annotate_playable_by_actor(music_utils.get_actor_from_request(self.request)) + ).with_playable_plts(music_utils.get_actor_from_request(self.request)) def perform_create(self, serializer): return serializer.save( diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py index b8cf4b4bc444c3d8f4b03f08c1cedc0e948f139c..8aa9c9dbe893d7d1e0974cfc79def18e6e12f29c 100644 --- a/api/funkwhale_api/subsonic/views.py +++ b/api/funkwhale_api/subsonic/views.py @@ -193,12 +193,17 @@ class SubsonicViewSet(viewsets.GenericViewSet): @list_route(methods=["get", "post"], url_name="stream", url_path="stream") @find_object(music_models.Track.objects.all(), filter_playable=True) def stream(self, request, *args, **kwargs): + data = request.GET or request.POST track = kwargs.pop("obj") queryset = track.uploads.select_related("track__album__artist", "track__artist") upload = queryset.first() if not upload: return response.Response(status=404) - return music_views.handle_serve(upload=upload, user=request.user) + + format = data.get("format", "raw") + if format == "raw": + format = None + return music_views.handle_serve(upload=upload, user=request.user, format=format) @list_route(methods=["get", "post"], url_name="star", url_path="star") @find_object(music_models.Track.objects.all()) diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 246525b99070a02d5b6a456a318f694231b1b24a..06fbd4cc452659486cfdad4ddf748b55e5fdb6f2 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -69,3 +69,4 @@ django-cleanup==2.1.0 # for LDAP authentication python-ldap==3.1.0 django-auth-ldap==1.7.0 +pydub==0.23.0 diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py index 6ac244c69fac164b49171b3d0f8dba20f13aa02d..0b99c93409a7ca1eb542fe2607d29a3b54c1293c 100644 --- a/api/tests/favorites/test_favorites.py +++ b/api/tests/favorites/test_favorites.py @@ -35,7 +35,6 @@ def test_user_can_get_his_favorites(api_request, factories, logged_in_client, cl "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"), } ] - expected[0]["track"]["is_playable"] = False assert response.status_code == 200 assert response.data["results"] == expected diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index d045a04c258ce61ee3c42c065e6768581a2e167d..874c35afa53a7e1a0a56cda2d3b5c5e91a07f3b7 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -464,24 +464,6 @@ def test_library_queryset_with_follows(factories): assert l2._follows == [follow] -def test_annotate_duration(factories): - tf = factories["music.Upload"](duration=32) - - track = models.Track.objects.annotate_duration().get(pk=tf.track.pk) - - assert track.duration == 32 - - -def test_annotate_file_data(factories): - tf = factories["music.Upload"](size=42, bitrate=55, mimetype="audio/ogg") - - track = models.Track.objects.annotate_file_data().get(pk=tf.track.pk) - - assert track.size == 42 - assert track.bitrate == 55 - assert track.mimetype == "audio/ogg" - - @pytest.mark.parametrize( "model,factory_args,namespace", [ diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py index 330371834d628713ea9cd2fc1cbfe772f19a65a7..3bd13a599150ee8a32af3e00932bd58454853df9 100644 --- a/api/tests/music/test_serializers.py +++ b/api/tests/music/test_serializers.py @@ -48,6 +48,7 @@ def test_artist_with_albums_serializer(factories, to_api_date): def test_album_track_serializer(factories, to_api_date): upload = factories["music.Upload"]() track = upload.track + setattr(track, "playable_uploads", [upload]) expected = { "id": track.id, @@ -56,7 +57,7 @@ def test_album_track_serializer(factories, to_api_date): "mbid": str(track.mbid), "title": track.title, "position": track.position, - "is_playable": None, + "uploads": [serializers.TrackUploadSerializer(upload).data], "creation_date": to_api_date(track.creation_date), "listen_url": track.listen_url, "duration": None, @@ -127,7 +128,7 @@ 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), - "is_playable": None, + "is_playable": False, "cover": { "original": album.cover.url, "square_crop": album.cover.crop["400x400"].url, @@ -145,7 +146,7 @@ def test_album_serializer(factories, to_api_date): def test_track_serializer(factories, to_api_date): upload = factories["music.Upload"]() track = upload.track - + setattr(track, "playable_uploads", [upload]) expected = { "id": track.id, "artist": serializers.ArtistSimpleSerializer(track.artist).data, @@ -153,14 +154,10 @@ def test_track_serializer(factories, to_api_date): "mbid": str(track.mbid), "title": track.title, "position": track.position, - "is_playable": None, + "uploads": [serializers.TrackUploadSerializer(upload).data], "creation_date": to_api_date(track.creation_date), "lyrics": track.get_lyrics_url(), "listen_url": track.listen_url, - "duration": None, - "size": None, - "bitrate": None, - "mimetype": None, } serializer = serializers.TrackSerializer(track) assert serializer.data == expected @@ -260,3 +257,20 @@ def test_manage_upload_action_relaunch_import(factories, mocker): finished.refresh_from_db() assert finished.import_status == "finished" assert m.call_count == 3 + + +def test_track_upload_serializer(factories): + upload = factories["music.Upload"]() + + expected = { + "listen_url": upload.listen_url, + "uuid": str(upload.uuid), + "size": upload.size, + "bitrate": upload.bitrate, + "mimetype": upload.mimetype, + "extension": upload.extension, + "duration": upload.duration, + } + + serializer = serializers.TrackUploadSerializer(upload) + assert serializer.data == expected diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py index efa0e801f42dfe6c785a35ca4ee70d63dfd8be82..4e03d7906c674a34f9947d71f0684261ad4cad33 100644 --- a/api/tests/music/test_tasks.py +++ b/api/tests/music/test_tasks.py @@ -546,3 +546,20 @@ def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock scan.refresh_from_db() assert scan.status == "finished" + + +def test_clean_transcoding_cache(preferences, now, factories): + preferences["music__transcoding_cache_duration"] = 60 + u1 = factories["music.UploadVersion"]( + accessed_date=now - datetime.timedelta(minutes=61) + ) + u2 = factories["music.UploadVersion"]( + accessed_date=now - datetime.timedelta(minutes=59) + ) + + tasks.clean_transcoding_cache() + + u2.refresh_from_db() + + with pytest.raises(u1.__class__.DoesNotExist): + u1.refresh_from_db() diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 389306820268157cc2e3d40a08bcc980edd4625d..615a592438990b5084679a8e8053974e3c6bc3f7 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1,11 +1,12 @@ import io +import magic import os import pytest from django.urls import reverse from django.utils import timezone -from funkwhale_api.music import serializers, tasks, views +from funkwhale_api.music import models, serializers, tasks, views from funkwhale_api.federation import api_serializers as federation_api_serializers DATA_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -38,13 +39,11 @@ def test_album_list_serializer(api_request, factories, logged_in_api_client): ).track album = track.album request = api_request.get("/") - qs = album.__class__.objects.all() + qs = album.__class__.objects.with_prefetched_tracks_and_playable_uploads(None) serializer = serializers.AlbumSerializer( qs, many=True, context={"request": request} ) expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} - expected["results"][0]["is_playable"] = True - expected["results"][0]["tracks"][0]["is_playable"] = True url = reverse("api:v1:albums-list") response = logged_in_api_client.get(url) @@ -57,12 +56,11 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client): library__privacy_level="everyone", import_status="finished" ).track request = api_request.get("/") - qs = track.__class__.objects.all() + qs = track.__class__.objects.with_playable_uploads(None) serializer = serializers.TrackSerializer( qs, many=True, context={"request": request} ) expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} - expected["results"][0]["is_playable"] = True url = reverse("api:v1:tracks-list") response = logged_in_api_client.get(url) @@ -309,7 +307,69 @@ def test_listen_explicit_file(factories, logged_in_api_client, mocker): response = logged_in_api_client.get(url, {"upload": upload2.uuid}) assert response.status_code == 200 - mocked_serve.assert_called_once_with(upload2, user=logged_in_api_client.user) + mocked_serve.assert_called_once_with( + upload2, user=logged_in_api_client.user, format=None + ) + + +@pytest.mark.parametrize( + "mimetype,format,expected", + [ + # already in proper format + ("audio/mpeg", "mp3", False), + # empty mimetype / format + (None, "mp3", False), + ("audio/mpeg", None, False), + # unsupported format + ("audio/mpeg", "noop", False), + # should transcode + ("audio/mpeg", "ogg", True), + ], +) +def test_should_transcode(mimetype, format, expected, factories): + upload = models.Upload(mimetype=mimetype) + assert views.should_transcode(upload, format) is expected + + +@pytest.mark.parametrize("value", [True, False]) +def test_should_transcode_according_to_preference(value, preferences, factories): + upload = models.Upload(mimetype="audio/ogg") + expected = value + preferences["music__transcoding_enabled"] = value + + assert views.should_transcode(upload, "mp3") is expected + + +def test_handle_serve_create_mp3_version(factories, now): + user = factories["users.User"]() + upload = factories["music.Upload"](bitrate=42) + response = views.handle_serve(upload, user, format="mp3") + + version = upload.versions.latest("id") + + assert version.mimetype == "audio/mpeg" + assert version.accessed_date == now + assert version.bitrate == upload.bitrate + assert version.audio_file.path.endswith(".mp3") + assert version.size == version.audio_file.size + assert magic.from_buffer(version.audio_file.read(), mime=True) == "audio/mpeg" + + assert response.status_code == 200 + + +def test_listen_transcode(factories, now, logged_in_api_client, mocker): + upload = factories["music.Upload"]( + import_status="finished", library__actor__user=logged_in_api_client.user + ) + url = reverse("api:v1:listen-detail", kwargs={"uuid": upload.track.uuid}) + handle_serve = mocker.spy(views, "handle_serve") + response = logged_in_api_client.get(url, {"to": "mp3"}) + + assert response.status_code == 200 + + handle_serve.assert_called_once_with( + upload, user=logged_in_api_client.user, format="mp3" + ) def test_user_can_create_library(factories, logged_in_api_client): diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py index 46c14d11c7f34ab7088840a60e5e57eb726b2a95..b90f525184b2f4863d8d9f7ab07314ac1e1a1712 100644 --- a/api/tests/playlists/test_models.py +++ b/api/tests/playlists/test_models.py @@ -150,10 +150,6 @@ def test_playlist_playable_by_anonymous(privacy_level, expected, factories): factories["music.Upload"]( track=track, library__privacy_level=privacy_level, import_status="finished" ) - queryset = playlist.__class__.objects.playable_by(None).annotate_playable_by_actor( - None - ) + queryset = playlist.__class__.objects.playable_by(None).with_playable_plts(None) match = playlist in list(queryset) assert match is expected - if expected: - assert bool(queryset.first().is_playable_by_actor) is expected diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py index 1256347f3e379322020838e7504263703fbe267a..1c2b0f19eea43fce59dcf0ed166badd8e5603265 100644 --- a/api/tests/playlists/test_views.py +++ b/api/tests/playlists/test_views.py @@ -145,7 +145,7 @@ def test_can_list_tracks_from_playlist(level, factories, logged_in_api_client): url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk}) response = logged_in_api_client.get(url) serialized_plt = serializers.PlaylistTrackSerializer(plt).data - serialized_plt["track"]["is_playable"] = False + assert response.data["count"] == 1 assert response.data["results"][0] == serialized_plt diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index 7cf5e8f120312004a1821738a60c80e400b9fc99..c0ae8407362b1fca31573b4b895ed0a58ca5097f 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -4,9 +4,9 @@ import json import pytest from django.urls import reverse from django.utils import timezone +from rest_framework.response import Response import funkwhale_api - from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views from funkwhale_api.subsonic import renderers, serializers @@ -199,11 +199,28 @@ def test_stream(f, db, logged_in_api_client, factories, mocker, queryset_equal_q playable_by = mocker.spy(music_models.TrackQuerySet, "playable_by") response = logged_in_api_client.get(url, {"f": f, "id": upload.track.pk}) - mocked_serve.assert_called_once_with(upload=upload, user=logged_in_api_client.user) + mocked_serve.assert_called_once_with( + upload=upload, user=logged_in_api_client.user, format=None + ) assert response.status_code == 200 playable_by.assert_called_once_with(music_models.Track.objects.all(), None) +@pytest.mark.parametrize("format,expected", [("mp3", "mp3"), ("raw", None)]) +def test_stream_format(format, expected, logged_in_api_client, factories, mocker): + url = reverse("api:subsonic-stream") + mocked_serve = mocker.patch.object( + music_views, "handle_serve", return_value=Response() + ) + upload = factories["music.Upload"](playable=True) + response = logged_in_api_client.get(url, {"id": upload.track.pk, "format": format}) + + mocked_serve.assert_called_once_with( + upload=upload, user=logged_in_api_client.user, format=expected + ) + assert response.status_code == 200 + + @pytest.mark.parametrize("f", ["xml", "json"]) def test_star(f, db, logged_in_api_client, factories): url = reverse("api:subsonic-star") diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index ce6aebbc3bcdd980207c22b70bc5615bbe780905..c6b8aea60e59b084edff401d25ff2af163f3f756 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -134,7 +134,7 @@ def test_import_files_skip_if_path_already_imported(factories, mocker): ) call_command( - "import_files", str(library.uuid), path, async=False, interactive=False + "import_files", str(library.uuid), path, async_=False, interactive=False ) assert library.uploads.count() == 1 diff --git a/changes/changelog.d/272.feature b/changes/changelog.d/272.feature new file mode 100644 index 0000000000000000000000000000000000000000..8a14193704f2ca1b835fc674967036a7a336e6d3 --- /dev/null +++ b/changes/changelog.d/272.feature @@ -0,0 +1,13 @@ +Audio transcoding is back! (#272) + + +Audio transcoding is back! +-------------------------- + +After removal of our first, buggy transcoding implementation, we're proud to announce +that this feature is back. It is enabled by default, and can be configured/disabled +in your instance settings! + +This feature works in the browser, with federated/non-federated tracks and using Subsonic clients. +Transcoded tracks are generated on the fly, and cached for a configurable amount of time, +to reduce the load on the server. diff --git a/changes/changelog.d/586.enhancement b/changes/changelog.d/586.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..bee8c1c968f5ac7d5a9541235562ad675075bd25 --- /dev/null +++ b/changes/changelog.d/586.enhancement @@ -0,0 +1 @@ +The progress bar in the player now display loading state / buffer loading (#586) diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index dcb1c507e121441aa9712aaca403f8dcc5759861..521a66f8715d0fce86e569ea1c2daa41e20630f6 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -79,10 +79,14 @@ export default { return true } if (this.track) { - return this.track.is_playable + return this.track.uploads && this.track.uploads.length > 0 + } else if (this.artist) { + return this.albums.filter((a) => { + return a.is_playable === true + }).length > 0 } else if (this.tracks) { return this.tracks.filter((t) => { - return t.is_playable + return t.uploads && t.uploads.length > 0 }).length > 0 } return false @@ -139,7 +143,7 @@ export default { self.isLoading = false }, 250) return tracks.filter(e => { - return e.is_playable === true + return e.uploads && e.uploads.length > 0 }) }) }, diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 5387d02b753e4b5626d637eaf78a57a4b5814cb9..80c54bbb2d9ad0f41174894e1a13611fee28250d 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -4,6 +4,7 @@ <audio-track ref="currentAudio" v-if="currentTrack" + @errored="handleError" :is-current="true" :start-time="$store.state.player.currentTime" :autoplay="$store.state.player.playing" @@ -41,21 +42,36 @@ </div> </div> </div> - <div class="progress-area" v-if="currentTrack"> + <div class="progress-area" v-if="currentTrack && !errored"> <div class="ui grid"> <div class="left floated four wide column"> <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p> </div> - <div class="right floated four wide column"> + <div v-if="!isLoadingAudio" class="right floated four wide column"> <p class="timer total">{{durationFormatted}}</p> </div> </div> - <div ref="progress" class="ui small orange inverted progress" @click="touchProgress"> + <div + ref="progress" + :class="['ui', 'small', 'orange', 'inverted', {'indicating': isLoadingAudio}, 'progress']" + @click="touchProgress"> + <div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div> <div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div> </div> </div> - + <div class="ui small warning message" v-if="currentTrack && errored"> + <div class="header"> + <translate>We cannot load this track</translate> + </div> + <p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors"> + <translate>The next track will play automatically in a few seconds...</translate> + <i class="loading spinner icon"></i> + </p> + <p> + <translate>You may have a connectivity issue.</translate> + </p> + </div> <div class="two wide column controls ui grid"> <a href @@ -295,15 +311,22 @@ export default { } let image = this.$refs.cover this.ambiantColors = ColorThief.prototype.getPalette(image, 4).slice(0, 4) + }, + handleError ({sound, error}) { + this.$store.commit('player/isLoadingAudio', false) + this.$store.dispatch('player/trackErrored') } }, computed: { ...mapState({ currentIndex: state => state.queue.currentIndex, playing: state => state.player.playing, + isLoadingAudio: state => state.player.isLoadingAudio, volume: state => state.player.volume, looping: state => state.player.looping, duration: state => state.player.duration, + bufferProgress: state => state.player.bufferProgress, + errored: state => state.player.errored, queue: state => state.queue }), ...mapGetters({ @@ -522,4 +545,43 @@ export default { margin: 0; } + +@keyframes MOVE-BG { + from { + transform: translateX(0px); + } + to { + transform: translateX(46px); + } +} + +.indicating.progress { + overflow: hidden; +} + +.ui.progress .bar { + transition: none; +} + +.ui.inverted.progress .buffer.bar { + position: absolute; + background-color:rgba(255, 255, 255, 0.15); +} +.indicating.progress .bar { + left: -46px; + width: 200% !important; + color: grey; + background: repeating-linear-gradient( + -55deg, + grey 1px, + grey 10px, + transparent 10px, + transparent 20px, + ) !important; + + animation-name: MOVE-BG; + animation-duration: 2s; + animation-timing-function: linear; + animation-iteration-count: infinite; +} </style> diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index 5626dd4d89bf41b64563aa93faa799d323f4051f..c847d4de1a69f3f6cd1e6937e580d8ddf23332da 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -44,11 +44,22 @@ export default { } }, onload: function () { + self.$store.commit('player/isLoadingAudio', false) self.$store.commit('player/resetErrorCount') + self.$store.commit('player/errored', false) self.$store.commit('player/duration', self.sound.duration()) - } + let node = self.sound._sounds[0]._node; + node.addEventListener('progress', () => { + self.updateBuffer(node) + }) + }, + onloaderror: function (sound, error) { + console.log('Error while playing:', sound, error) + self.$emit('errored', {sound, error}) + }, }) if (this.autoplay) { + self.$store.commit('player/isLoadingAudio', true) this.sound.play() this.$store.commit('player/playing', true) this.observeProgress(true) @@ -67,14 +78,23 @@ export default { looping: state => state.player.looping }), srcs: function () { - // let file = this.track.files[0] - // if (!file) { - // this.$store.dispatch('player/trackErrored') - // return [] - // } - let sources = [ - {type: 'mp3', url: this.$store.getters['instance/absoluteUrl'](this.track.listen_url)} - ] + let sources = this.track.uploads.map(u => { + return { + type: u.extension, + url: this.$store.getters['instance/absoluteUrl'](u.listen_url), + } + }) + // We always add a transcoded MP3 src at the end + // because transcoding is expensive, but we want browsers that do + // not support other codecs to be able to play it :) + sources.push({ + type: 'mp3', + url: url.updateQueryString( + this.$store.getters['instance/absoluteUrl'](this.track.listen_url), + 'to', + 'mp3' + ) + }) if (this.$store.state.auth.authenticated) { // we need to send the token directly in url // so authentication can be checked by the backend @@ -91,10 +111,40 @@ export default { } }, methods: { + updateBuffer (node) { + // from https://github.com/goldfire/howler.js/issues/752#issuecomment-372083163 + let range = 0; + let bf = node.buffered; + let time = node.currentTime; + try { + while(!(bf.start(range) <= time && time <= bf.end(range))) { + range += 1; + } + } catch (IndexSizeError) { + return + } + let loadPercentage + let start = bf.start(range) + let end = bf.end(range) + if (range === 0) { + // easy case, no user-seek + let loadStartPercentage = start / node.duration; + let loadEndPercentage = end / node.duration; + loadPercentage = loadEndPercentage - loadStartPercentage; + } else { + let loaded = end - start + let remainingToLoad = node.duration - start + // user seeked a specific position in the audio, our progress must be + // computed based on the remaining portion of the track + loadPercentage = loaded / remainingToLoad; + } + this.$store.commit('player/bufferProgress', loadPercentage * 100) + }, updateProgress: function () { this.isUpdatingTime = true if (this.sound && this.sound.state() === 'loaded') { this.$store.dispatch('player/updateProgress', this.sound.seek()) + this.updateBuffer(this.sound._sounds[0]._node) } }, observeProgress: function (enable) { diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index b17cf117073190f767e82894de09d12106712d47..fd8b2daaf134473aff45cef895b104bc3a7f6ba7 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -34,8 +34,8 @@ {{ track.album.title }} </router-link> </td> - <td colspan="4" v-if="track.duration"> - {{ time.parse(track.duration) }} + <td colspan="4" v-if="track.uploads && track.uploads.length > 0"> + {{ time.parse(track.uploads[0].duration) }} </td> <td colspan="4" v-else> <translate>N/A</translate> diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue index 483ff66739f582b3facd15237879c9321ae1c841..ddccda397352bc578999608489f153519f332fe8 100644 --- a/front/src/components/library/Track.vue +++ b/front/src/components/library/Track.vue @@ -44,13 +44,13 @@ <i class="external icon"></i> <translate>View on MusicBrainz</translate> </a> - <a v-if="track.is_playable" :href="downloadUrl" target="_blank" class="ui button"> + <a v-if="upload" :href="downloadUrl" target="_blank" class="ui button"> <i class="download icon"></i> <translate>Download</translate> </a> </div> </div> - <div class="ui vertical stripe center aligned segment"> + <div class="ui vertical stripe center aligned segment" v-if="upload"> <h2 class="ui header"><translate>Track information</translate></h2> <table class="ui very basic collapsing celled center aligned table"> <tbody> @@ -58,8 +58,8 @@ <td> <translate>Duration</translate> </td> - <td v-if="track.duration"> - {{ time.parse(track.duration) }} + <td v-if="upload.duration"> + {{ time.parse(upload.duration) }} </td> <td v-else> <translate>N/A</translate> @@ -69,8 +69,8 @@ <td> <translate>Size</translate> </td> - <td v-if="track.size"> - {{ track.size | humanSize }} + <td v-if="upload.size"> + {{ upload.size | humanSize }} </td> <td v-else> <translate>N/A</translate> @@ -80,8 +80,8 @@ <td> <translate>Bitrate</translate> </td> - <td v-if="track.bitrate"> - {{ track.bitrate | humanSize }}/s + <td v-if="upload.bitrate"> + {{ upload.bitrate | humanSize }}/s </td> <td v-else> <translate>N/A</translate> @@ -91,8 +91,8 @@ <td> <translate>Type</translate> </td> - <td v-if="track.mimetype"> - {{ track.mimetype }} + <td v-if="upload.extension"> + {{ upload.extension }} </td> <td v-else> <translate>N/A</translate> @@ -195,6 +195,11 @@ export default { title: this.$gettext('Track') } }, + upload () { + if (this.track.uploads) { + return this.track.uploads[0] + } + }, wikipediaUrl () { return 'https://en.wikipedia.org/w/index.php?search=' + encodeURI(this.track.title + ' ' + this.track.artist.name) }, @@ -204,7 +209,7 @@ export default { } }, downloadUrl () { - let u = this.$store.getters['instance/absoluteUrl'](this.track.listen_url) + let u = this.$store.getters['instance/absoluteUrl'](this.upload.listen_url) if (this.$store.state.auth.authenticated) { u = url.updateQueryString(u, 'jwt', encodeURI(this.$store.state.auth.token)) } diff --git a/front/src/store/index.js b/front/src/store/index.js index 051e89b39e11291f7b303056399faa56e8abed15..0b8eb3321c27b00d8c31498ad602de7f9617c49a 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -79,6 +79,7 @@ export default new Vuex.Store({ id: track.id, title: track.title, mbid: track.mbid, + uploads: track.uploads, listen_url: track.listen_url, album: { id: track.album.id, diff --git a/front/src/store/player.js b/front/src/store/player.js index dc01f368b0c056499d39502eb24459197caa6366..fac17368d10377b959fb98520f242eb2f77f4aa2 100644 --- a/front/src/store/player.js +++ b/front/src/store/player.js @@ -8,11 +8,13 @@ export default { maxConsecutiveErrors: 5, errorCount: 0, playing: false, + isLoadingAudio: false, volume: 0.5, tempVolume: 0.5, duration: 0, currentTime: 0, errored: false, + bufferProgress: 0, looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue }, mutations: { @@ -59,12 +61,18 @@ export default { playing (state, value) { state.playing = value }, + bufferProgress (state, value) { + state.bufferProgress = value + }, toggleLooping (state) { if (state.looping > 1) { state.looping = 0 } else { state.looping += 1 } + }, + isLoadingAudio (state, value) { + state.isLoadingAudio = value } }, getters: { @@ -87,10 +95,19 @@ export default { incrementVolume ({commit, state}, value) { commit('volume', state.volume + value) }, - stop (context) { + stop ({commit}) { + commit('errored', false) + commit('resetErrorCount') }, - togglePlay ({commit, state}) { + togglePlay ({commit, state, dispatch}) { commit('playing', !state.playing) + if (state.errored && state.errorCount < state.maxConsecutiveErrors) { + setTimeout(() => { + if (state.playing) { + dispatch('queue/next', null, {root: true}) + } + }, 3000) + } }, trackListened ({commit, rootState}, track) { if (!rootState.auth.authenticated) { @@ -113,7 +130,13 @@ export default { trackErrored ({commit, dispatch, state}) { commit('errored', true) commit('incrementErrorCount') - dispatch('queue/next', null, {root: true}) + if (state.errorCount < state.maxConsecutiveErrors) { + setTimeout(() => { + if (state.playing) { + dispatch('queue/next', null, {root: true}) + } + }, 3000) + } }, updateProgress ({commit}, t) { commit('currentTime', t) diff --git a/front/src/store/queue.js b/front/src/store/queue.js index b6edb2242a3d09092baacf3b5b3af817bc9695dd..81403b11fa6d77d2e5332a77ed12ba3023d3f00e 100644 --- a/front/src/store/queue.js +++ b/front/src/store/queue.js @@ -142,7 +142,6 @@ export default { commit('ended', false) commit('player/currentTime', 0, {root: true}) commit('player/playing', true, {root: true}) - commit('player/errored', false, {root: true}) commit('currentIndex', index) if (state.tracks.length - index <= 2 && rootState.radios.running) { dispatch('radios/populateQueue', null, {root: true}) diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue index 43bb54de337ffc4daa6cc30317f0846b214a3e01..0aa47a5c216498296fa7279a1a192c822a4c9cb4 100644 --- a/front/src/views/admin/Settings.vue +++ b/front/src/views/admin/Settings.vue @@ -79,6 +79,7 @@ export default { // somehow, extraction fails if in the return block directly let instanceLabel = this.$gettext('Instance information') let usersLabel = this.$gettext('Users') + let musicLabel = this.$gettext('Music') let playlistsLabel = this.$gettext('Playlists') let federationLabel = this.$gettext('Federation') let subsonicLabel = this.$gettext('Subsonic') @@ -104,6 +105,14 @@ export default { 'users__upload_quota' ] }, + { + label: musicLabel, + id: 'music', + settings: [ + 'music__transcoding_enabled', + 'music__transcoding_cache_duration', + ] + }, { label: playlistsLabel, id: 'playlists', diff --git a/front/tests/unit/specs/store/queue.spec.js b/front/tests/unit/specs/store/queue.spec.js index 373f4938e034864d65db318ba82a550fdcc012c4..282a4f02633758d878f7a89106844b6981fcccfd 100644 --- a/front/tests/unit/specs/store/queue.spec.js +++ b/front/tests/unit/specs/store/queue.spec.js @@ -267,7 +267,6 @@ describe('store/queue', () => { { type: 'ended', payload: false }, { type: 'player/currentTime', payload: 0, options: {root: true} }, { type: 'player/playing', payload: true, options: {root: true} }, - { type: 'player/errored', payload: false, options: {root: true} }, { type: 'currentIndex', payload: 1 } ] }) @@ -281,7 +280,6 @@ describe('store/queue', () => { { type: 'ended', payload: false }, { type: 'player/currentTime', payload: 0, options: {root: true} }, { type: 'player/playing', payload: true, options: {root: true} }, - { type: 'player/errored', payload: false, options: {root: true} }, { type: 'currentIndex', payload: 1 } ] }) @@ -295,7 +293,6 @@ describe('store/queue', () => { { type: 'ended', payload: false }, { type: 'player/currentTime', payload: 0, options: {root: true} }, { type: 'player/playing', payload: true, options: {root: true} }, - { type: 'player/errored', payload: false, options: {root: true} }, { type: 'currentIndex', payload: 1 } ], expectedActions: [