Commit 2739a5fb authored by Eliot Berriot's avatar Eliot Berriot 💬

Merge branch '272-transcoding' into 'develop'

Fix #272 and #586

Closes #272 and #586

See merge request funkwhale/funkwhale!458
parents c3fece42 bcd22eb3
...@@ -412,7 +412,12 @@ CELERY_BEAT_SCHEDULE = { ...@@ -412,7 +412,12 @@ CELERY_BEAT_SCHEDULE = {
"task": "federation.clean_music_cache", "task": "federation.clean_music_cache",
"schedule": crontab(hour="*/2"), "schedule": crontab(hour="*/2"),
"options": {"expires": 60 * 2}, "options": {"expires": 60 * 2},
} },
"music.clean_transcoding_cache": {
"task": "music.clean_transcoding_cache",
"schedule": crontab(hour="*"),
"options": {"expires": 60 * 2},
},
} }
JWT_AUTH = { JWT_AUTH = {
......
...@@ -3,7 +3,7 @@ import re ...@@ -3,7 +3,7 @@ import re
from django.db.models import Q 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): def parse_query(query):
......
...@@ -51,7 +51,7 @@ class TrackFavoriteViewSet( ...@@ -51,7 +51,7 @@ class TrackFavoriteViewSet(
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") 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) music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist") ).select_related("artist", "album__artist")
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
......
...@@ -41,7 +41,7 @@ class ListeningViewSet( ...@@ -41,7 +41,7 @@ class ListeningViewSet(
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") 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) music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist") ).select_related("artist", "album__artist")
return queryset.prefetch_related(Prefetch("track", queryset=tracks)) return queryset.prefetch_related(Prefetch("track", queryset=tracks))
......
...@@ -78,6 +78,28 @@ class UploadAdmin(admin.ModelAdmin): ...@@ -78,6 +78,28 @@ class UploadAdmin(admin.ModelAdmin):
list_filter = ["mimetype", "import_status", "library__privacy_level"] 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): def launch_scan(modeladmin, request, queryset):
for library in queryset: for library in queryset:
library.schedule_scan(actor=request.user.actor, force=True) library.schedule_scan(actor=request.user.actor, force=True)
......
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}
...@@ -95,6 +95,18 @@ class UploadFactory(factory.django.DjangoModelFactory): ...@@ -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 @registry.register
class WorkFactory(factory.django.DjangoModelFactory): class WorkFactory(factory.django.DjangoModelFactory):
mbid = factory.Faker("uuid4") mbid = factory.Faker("uuid4")
......
# 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')},
),
]
...@@ -11,7 +11,7 @@ from django.conf import settings ...@@ -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
...@@ -124,8 +124,8 @@ class ArtistQuerySet(models.QuerySet): ...@@ -124,8 +124,8 @@ class ArtistQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor): def annotate_playable_by_actor(self, actor):
tracks = ( tracks = (
Track.objects.playable_by(actor) Upload.objects.playable_by(actor)
.filter(artist=models.OuterRef("id")) .filter(track__artist=models.OuterRef("id"))
.order_by("id") .order_by("id")
.values("id")[:1] .values("id")[:1]
) )
...@@ -192,8 +192,8 @@ class AlbumQuerySet(models.QuerySet): ...@@ -192,8 +192,8 @@ class AlbumQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor): def annotate_playable_by_actor(self, actor):
tracks = ( tracks = (
Track.objects.playable_by(actor) Upload.objects.playable_by(actor)
.filter(album=models.OuterRef("id")) .filter(track__album=models.OuterRef("id"))
.order_by("id") .order_by("id")
.values("id")[:1] .values("id")[:1]
) )
...@@ -207,6 +207,10 @@ class AlbumQuerySet(models.QuerySet): ...@@ -207,6 +207,10 @@ class AlbumQuerySet(models.QuerySet):
else: else:
return self.exclude(tracks__in=tracks).distinct() 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): class Album(APIModelMixin):
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
...@@ -403,18 +407,10 @@ class TrackQuerySet(models.QuerySet): ...@@ -403,18 +407,10 @@ class TrackQuerySet(models.QuerySet):
else: else:
return self.exclude(uploads__in=files).distinct() return self.exclude(uploads__in=files).distinct()
def annotate_duration(self): def with_playable_uploads(self, actor):
first_upload = Upload.objects.filter(track=models.OuterRef("pk")).order_by("pk") uploads = Upload.objects.playable_by(actor).select_related("track")
return self.annotate( return self.prefetch_related(
duration=models.Subquery(first_upload.values("duration")[:1]) models.Prefetch("uploads", queryset=uploads, to_attr="playable_uploads")
)
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]),
) )
...@@ -578,6 +574,9 @@ TRACK_FILE_IMPORT_STATUS_CHOICES = ( ...@@ -578,6 +574,9 @@ TRACK_FILE_IMPORT_STATUS_CHOICES = (
def get_file_path(instance, filename): def get_file_path(instance, filename):
if isinstance(instance, UploadVersion):
return common_utils.ChunkedPath("transcoded")(instance, filename)
if instance.library.actor.get_user(): if instance.library.actor.get_user():
return common_utils.ChunkedPath("tracks")(instance, filename) return common_utils.ChunkedPath("tracks")(instance, filename)
else: else:
...@@ -741,6 +740,61 @@ class Upload(models.Model): ...@@ -741,6 +740,61 @@ 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 = [(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 = ( IMPORT_STATUS_CHOICES = (
("pending", "Pending"), ("pending", "Pending"),
......
...@@ -59,7 +59,7 @@ class ArtistSimpleSerializer(serializers.ModelSerializer): ...@@ -59,7 +59,7 @@ class ArtistSimpleSerializer(serializers.ModelSerializer):
class AlbumTrackSerializer(serializers.ModelSerializer): class AlbumTrackSerializer(serializers.ModelSerializer):
artist = ArtistSimpleSerializer(read_only=True) artist = ArtistSimpleSerializer(read_only=True)
is_playable = serializers.SerializerMethodField() uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField()
duration = serializers.SerializerMethodField() duration = serializers.SerializerMethodField()
...@@ -73,16 +73,14 @@ class AlbumTrackSerializer(serializers.ModelSerializer): ...@@ -73,16 +73,14 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
"artist", "artist",
"creation_date", "creation_date",
"position", "position",
"is_playable", "uploads",
"listen_url", "listen_url",
"duration", "duration",
) )
def get_is_playable(self, obj): def get_uploads(self, obj):
try: uploads = getattr(obj, "playable_uploads", [])
return bool(obj.is_playable_by_actor) return TrackUploadSerializer(uploads, many=True).data
except AttributeError:
return None
def get_listen_url(self, obj): def get_listen_url(self, obj):
return obj.listen_url return obj.listen_url
...@@ -123,7 +121,9 @@ class AlbumSerializer(serializers.ModelSerializer): ...@@ -123,7 +121,9 @@ class AlbumSerializer(serializers.ModelSerializer):
def get_is_playable(self, obj): def get_is_playable(self, obj):
try: 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: except AttributeError:
return None return None
...@@ -145,16 +145,26 @@ class TrackAlbumSerializer(serializers.ModelSerializer): ...@@ -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): class TrackSerializer(serializers.ModelSerializer):
artist = ArtistSimpleSerializer(read_only=True) artist = ArtistSimpleSerializer(read_only=True)
album = TrackAlbumSerializer(read_only=True) album = TrackAlbumSerializer(read_only=True)
lyrics = serializers.SerializerMethodField() lyrics = serializers.SerializerMethodField()
is_playable = serializers.SerializerMethodField() uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField()
duration = serializers.SerializerMethodField()
bitrate = serializers.SerializerMethodField()
size = serializers.SerializerMethodField()
mimetype = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Track model = models.Track
...@@ -167,12 +177,8 @@ class TrackSerializer(serializers.ModelSerializer): ...@@ -167,12 +177,8 @@ class TrackSerializer(serializers.ModelSerializer):
"creation_date", "creation_date",
"position", "position",
"lyrics", "lyrics",
"is_playable", "uploads",
"listen_url", "listen_url",
"duration",
"bitrate",
"size",
"mimetype",
) )
def get_lyrics(self, obj): def get_lyrics(self, obj):
...@@ -181,35 +187,9 @@ class TrackSerializer(serializers.ModelSerializer): ...@@ -181,35 +187,9 @@ class TrackSerializer(serializers.ModelSerializer):
def get_listen_url(self, obj): def get_listen_url(self, obj):
return obj.listen_url return obj.listen_url
def get_is_playable(self, obj): def get_uploads(self, obj):
try: uploads = getattr(obj, "playable_uploads", [])
return bool(obj.is_playable_by_actor) return TrackUploadSerializer(uploads, many=True).data
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
class LibraryForOwnerSerializer(serializers.ModelSerializer): class LibraryForOwnerSerializer(serializers.ModelSerializer):
......
import collections import collections
import datetime
import logging import logging
import os import os
...@@ -10,7 +11,7 @@ from django.dispatch import receiver ...@@ -10,7 +11,7 @@ from django.dispatch import receiver
from musicbrainzngs import ResponseError from musicbrainzngs import ResponseError
from requests.exceptions import RequestException 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 routes
from funkwhale_api.federation import library as lb from funkwhale_api.federation import library as lb
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
...@@ -526,3 +527,19 @@ def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kw ...@@ -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()
...@@ -2,6 +2,7 @@ import mimetypes ...@@ -2,6 +2,7 @@ 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
...@@ -68,3 +69,10 @@ def get_actor_from_request(request): ...@@ -68,3 +69,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)
...@@ -15,8 +15,9 @@ from rest_framework.decorators import detail_route, list_route ...@@ -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
...@@ -92,17 +93,9 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -92,17 +93,9 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() 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) 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)) qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks))
return qs.distinct() return qs.distinct()
...@@ -193,18 +186,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): ...@@ -193,18 +186,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
if user.is_authenticated and filter_favorites == "true": if user.is_authenticated and filter_favorites == "true":
queryset = queryset.filter(track_favorites__user=user) 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) utils.get_actor_from_request(self.request)
).annotate_duration() )
if ( return queryset
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()
@detail_route(methods=["get"]) @detail_route(methods=["get"])
@transaction.non_atomic_requests @transaction.non_atomic_requests
...@@ -267,12 +252,31 @@ def get_file_path(audio_file): ...@@ -267,12 +252,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