From 2fe1e7c950bc9b994cea47a51386d2ae94ba993b Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Wed, 24 Oct 2018 19:14:51 +0200 Subject: [PATCH] See #272: added preference and base logic for transcoding --- .../music/dynamic_preferences_registry.py | 19 ++++++ api/funkwhale_api/music/models.py | 33 ++++++++- api/funkwhale_api/music/utils.py | 9 +++ api/funkwhale_api/music/views.py | 41 ++++++++++-- api/requirements/base.txt | 1 + api/tests/music/test_views.py | 67 ++++++++++++++++++- 6 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 api/funkwhale_api/music/dynamic_preferences_registry.py 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 000000000..d41225775 --- /dev/null +++ b/api/funkwhale_api/music/dynamic_preferences_registry.py @@ -0,0 +1,19 @@ +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 diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 71c511d33..c99bfe2c1 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 @@ -744,6 +744,37 @@ 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 diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 6da9ad949..f146dc344 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -2,8 +2,10 @@ import mimetypes import magic import mutagen +import pydub from funkwhale_api.common.search import normalize_query, get_query # noqa +from funkwhale_api.common import utils def guess_mimetype(f): @@ -68,3 +70,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 c8d1b94fc..c5cfeb25c 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 @@ -267,12 +268,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 +318,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 +365,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/requirements/base.txt b/api/requirements/base.txt index 246525b99..06fbd4cc4 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/music/test_views.py b/api/tests/music/test_views.py index 389306820..d4ce1e2bd 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__)) @@ -309,7 +310,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):