Commit 2fe1e7c9 authored by Eliot Berriot's avatar Eliot Berriot 💬

See #272: added preference and base logic for transcoding

parent baf5a350
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
music = types.Section("music")
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
......@@ -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)
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(
)[0] + '.{}'.format(format), f)
version.size = version.audio_file.size['size'])
return version
(mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE
......@@ -2,8 +2,10 @@ import mimetypes
import magic
import mutagen
import pydub
from 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 =
return actor
def transcode_file(input, output, input_format, output_format, **kwargs):
audio = pydub.AudioSegment.from_file(input, format=input_format)
return audio.export(output, format=output_format, **kwargs)
......@@ -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 =["accessed_date"])
now =
upload.accessed_date = now["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["accessed_date"])
f = transcoded_version
file_path = get_file_path(f.audio_file)
mt = f.mimetype
if mt:
response = Response(content_type=mt)
......@@ -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(
......@@ -69,3 +69,4 @@ django-cleanup==2.1.0
# for LDAP authentication
import io
import magic
import os
import pytest
from django.urls import reverse
from django.utils import timezone
from import serializers, tasks, views
from 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)
upload2, user=logged_in_api_client.user, format=None
# 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(, 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
upload, user=logged_in_api_client.user, format="mp3"
def test_user_can_create_library(factories, logged_in_api_client):
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment