See #272: added preference and base logic for transcoding
This commit is contained in:
parent
baf5a350b3
commit
2fe1e7c950
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue