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 !458
parents c3fece42 bcd22eb3
......@@ -412,7 +412,12 @@ CELERY_BEAT_SCHEDULE = {
"task": "federation.clean_music_cache",
"schedule": crontab(hour="*/2"),
"options": {"expires": 60 * 2},
}
},
"music.clean_transcoding_cache": {
"task": "music.clean_transcoding_cache",
"schedule": crontab(hour="*"),
"options": {"expires": 60 * 2},
},
}
JWT_AUTH = {
......
......@@ -3,7 +3,7 @@ import re
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):
......
......@@ -51,7 +51,7 @@ class TrackFavoriteViewSet(
queryset = queryset.filter(
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)
).select_related("artist", "album__artist")
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
......
......@@ -41,7 +41,7 @@ class ListeningViewSet(
queryset = queryset.filter(
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)
).select_related("artist", "album__artist")
return queryset.prefetch_related(Prefetch("track", queryset=tracks))
......
......@@ -78,6 +78,28 @@ class UploadAdmin(admin.ModelAdmin):
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):
for library in queryset:
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):
)
@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
class WorkFactory(factory.django.DjangoModelFactory):
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
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
......@@ -124,8 +124,8 @@ class ArtistQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor):
tracks = (
Track.objects.playable_by(actor)
.filter(artist=models.OuterRef("id"))
Upload.objects.playable_by(actor)
.filter(track__artist=models.OuterRef("id"))
.order_by("id")
.values("id")[:1]
)
......@@ -192,8 +192,8 @@ class AlbumQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor):
tracks = (
Track.objects.playable_by(actor)
.filter(album=models.OuterRef("id"))
Upload.objects.playable_by(actor)
.filter(track__album=models.OuterRef("id"))
.order_by("id")
.values("id")[:1]
)
......@@ -207,6 +207,10 @@ class AlbumQuerySet(models.QuerySet):
else:
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):
title = models.CharField(max_length=255)
......@@ -403,18 +407,10 @@ class TrackQuerySet(models.QuerySet):
else:
return self.exclude(uploads__in=files).distinct()
def annotate_duration(self):
first_upload = Upload.objects.filter(track=models.OuterRef("pk")).order_by("pk")
return self.annotate(
duration=models.Subquery(first_upload.values("duration")[:1])
)
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]),
def with_playable_uploads(self, actor):
uploads = Upload.objects.playable_by(actor).select_related("track")
return self.prefetch_related(
models.Prefetch("uploads", queryset=uploads, to_attr="playable_uploads")
)
......@@ -578,6 +574,9 @@ TRACK_FILE_IMPORT_STATUS_CHOICES = (
def get_file_path(instance, filename):
if isinstance(instance, UploadVersion):
return common_utils.ChunkedPath("transcoded")(instance, filename)
if instance.library.actor.get_user():
return common_utils.ChunkedPath("tracks")(instance, filename)
else:
......@@ -741,6 +740,61 @@ 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]
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 = (
("pending", "Pending"),
......
......@@ -59,7 +59,7 @@ class ArtistSimpleSerializer(serializers.ModelSerializer):
class AlbumTrackSerializer(serializers.ModelSerializer):
artist = ArtistSimpleSerializer(read_only=True)
is_playable = serializers.SerializerMethodField()
uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField()
duration = serializers.SerializerMethodField()
......@@ -73,16 +73,14 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
"artist",
"creation_date",
"position",
"is_playable",
"uploads",
"listen_url",
"duration",
)
def get_is_playable(self, obj):
try:
return bool(obj.is_playable_by_actor)
except AttributeError:
return None
def get_uploads(self, obj):
uploads = getattr(obj, "playable_uploads", [])
return TrackUploadSerializer(uploads, many=True).data
def get_listen_url(self, obj):
return obj.listen_url
......@@ -123,7 +121,9 @@ class AlbumSerializer(serializers.ModelSerializer):
def get_is_playable(self, obj):
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:
return None
......@@ -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):
artist = ArtistSimpleSerializer(read_only=True)
album = TrackAlbumSerializer(read_only=True)
lyrics = serializers.SerializerMethodField()
is_playable = serializers.SerializerMethodField()
uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField()
duration = serializers.SerializerMethodField()
bitrate = serializers.SerializerMethodField()
size = serializers.SerializerMethodField()
mimetype = serializers.SerializerMethodField()
class Meta:
model = models.Track
......@@ -167,12 +177,8 @@ class TrackSerializer(serializers.ModelSerializer):
"creation_date",
"position",
"lyrics",
"is_playable",
"uploads",
"listen_url",
"duration",
"bitrate",
"size",
"mimetype",
)
def get_lyrics(self, obj):
......@@ -181,35 +187,9 @@ class TrackSerializer(serializers.ModelSerializer):
def get_listen_url(self, obj):
return obj.listen_url
def get_is_playable(self, obj):
try:
return bool(obj.is_playable_by_actor)
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
def get_uploads(self, obj):
uploads = getattr(obj, "playable_uploads", [])
return TrackUploadSerializer(uploads, many=True).data
class LibraryForOwnerSerializer(serializers.ModelSerializer):
......
import collections
import datetime
import logging
import os
......@@ -10,7 +11,7 @@ from django.dispatch import receiver
from musicbrainzngs import ResponseError
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 library as lb
from funkwhale_api.taskapp import celery
......@@ -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
import magic
import mutagen
import pydub
from funkwhale_api.common.search import normalize_query, get_query # noqa
......@@ -68,3 +69,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
......@@ -92,17 +93,9 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
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)
).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))
return qs.distinct()
......@@ -193,18 +186,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
if user.is_authenticated and filter_favorites == "true":
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)
).annotate_duration()
if (
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()
)
return queryset
@detail_route(methods=["get"])
@transaction.non_atomic_requests
......@@ -267,12 +252,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 +302,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 +349,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(
......
......@@ -38,15 +38,14 @@ class PlaylistQuerySet(models.QuerySet):
)
return self.prefetch_related(plt_prefetch)
def annotate_playable_by_actor(self, actor):
plts = (
PlaylistTrack.objects.playable_by(actor)
.filter(playlist=models.OuterRef("id"))
.order_by("id")
.values("id")[:1]
def with_playable_plts(self, actor):
return self.prefetch_related(
models.Prefetch(
"playlist_tracks",
queryset=PlaylistTrack.objects.playable_by(actor),
to_attr="playable_plts",
)
)
subquery = models.Subquery(plts)
return self.annotate(is_playable_by_actor=subquery)
def playable_by(self, actor, include=True):
plts = PlaylistTrack.objects.playable_by(actor, include)
......@@ -148,7 +147,7 @@ class Playlist(models.Model):
class PlaylistTrackQuerySet(models.QuerySet):
def for_nested_serialization(self, actor=None):
tracks = music_models.Track.objects.annotate_playable_by_actor(actor)
tracks = music_models.Track.objects.with_playable_uploads(actor)
tracks = tracks.select_related("artist", "album__artist")
return self.prefetch_related(
models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track")
......@@ -156,8 +155,8 @@ class PlaylistTrackQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor):
tracks = (
music_models.Track.objects.playable_by(actor)
.filter(pk=models.OuterRef("track"))
music_models.Upload.objects.playable_by(actor)
.filter(track__pk=models.OuterRef("track"))
.order_by("id")
.values("id")[:1]
)
......
......@@ -93,7 +93,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
def get_is_playable(self, obj):
try:
return bool(obj.is_playable_by_actor)
return bool(obj.playable_plts)
except AttributeError:
return None
......
......@@ -78,7 +78,7 @@ class PlaylistViewSet(
def get_queryset(self):
return self.queryset.filter(
fields.privacy_level_query(self.request.user)
).annotate_playable_by_actor(music_utils.get_actor_from_request(self.request))
).with_playable_plts(music_utils.get_actor_from_request(self.request))
def perform_create(self, serializer):
return serializer.save(
......
......@@ -193,12 +193,17 @@ class SubsonicViewSet(viewsets.GenericViewSet):
@list_route(methods=["get", "post"], url_name="stream", url_path="stream")
@find_object(music_models.Track.objects.all(), filter_playable=True)
def stream(self, request, *args, **kwargs):
data = request.GET or request.POST
track = kwargs.pop("obj")
queryset = track.uploads.select_related("track__album__artist", "track__artist")
upload = queryset.first()
if not upload:
return response.Response(status=404)
return music_views.handle_serve(upload=upload, user=request.user)
format = data.get("format", "raw")
if format == "raw":
format = None
return music_views.handle_serve(upload=upload, user=request.user, format=format)
@list_route(methods=["get", "post"], url_name="star", url_path="star")
@find_object(music_models.Track.objects.all())
......
......@@ -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
......@@ -35,7 +35,6 @@ def test_user_can_get_his_favorites(api_request, factories, logged_in_client, cl
"creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"),
}