See #272: added preference and base logic for transcoding

This commit is contained in:
Eliot Berriot 2018-10-24 19:14:51 +02:00
parent baf5a350b3
commit 2fe1e7c950
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
6 changed files with 161 additions and 9 deletions

View File

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

View File

@ -11,7 +11,7 @@ from django.conf import settings
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder 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.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@ -744,6 +744,37 @@ class Upload(models.Model):
def listen_url(self): def listen_url(self):
return self.track.listen_url + "?upload={}".format(self.uuid) 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 = [ MIMETYPE_CHOICES = [
(mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE (mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE

View File

@ -2,8 +2,10 @@ import mimetypes
import magic import magic
import mutagen import mutagen
import pydub
from funkwhale_api.common.search import normalize_query, get_query # noqa from funkwhale_api.common.search import normalize_query, get_query # noqa
from funkwhale_api.common import utils
def guess_mimetype(f): def guess_mimetype(f):
@ -68,3 +70,10 @@ def get_actor_from_request(request):
actor = request.user.actor actor = request.user.actor
return 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)

View File

@ -15,8 +15,9 @@ from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response from rest_framework.response import Response
from taggit.models import Tag 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 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.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
@ -267,12 +268,31 @@ def get_file_path(audio_file):
return path.encode("utf-8") 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 f = upload
# we update the accessed_date # we update the accessed_date
f.accessed_date = timezone.now() now = timezone.now()
f.save(update_fields=["accessed_date"]) upload.accessed_date = now
upload.save(update_fields=["accessed_date"])
f = upload
if f.audio_file: if f.audio_file:
file_path = get_file_path(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://"): elif f.source and f.source.startswith("file://"):
file_path = get_file_path(f.source.replace("file://", "", 1)) file_path = get_file_path(f.source.replace("file://", "", 1))
mt = f.mimetype 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: if mt:
response = Response(content_type=mt) response = Response(content_type=mt)
else: else:
@ -337,7 +365,8 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
if not upload: if not upload:
return Response(status=404) 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( class UploadViewSet(

View File

@ -69,3 +69,4 @@ django-cleanup==2.1.0
# for LDAP authentication # for LDAP authentication
python-ldap==3.1.0 python-ldap==3.1.0
django-auth-ldap==1.7.0 django-auth-ldap==1.7.0
pydub==0.23.0

View File

@ -1,11 +1,12 @@
import io import io
import magic
import os import os
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 from funkwhale_api.federation import api_serializers as federation_api_serializers
DATA_DIR = os.path.dirname(os.path.abspath(__file__)) 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}) response = logged_in_api_client.get(url, {"upload": upload2.uuid})
assert response.status_code == 200 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): def test_user_can_create_library(factories, logged_in_api_client):