Merge branch '272-transcoding' into 'develop'

Fix #272 and #586

Closes #272 and #586

See merge request funkwhale/funkwhale!458
This commit is contained in:
Eliot Berriot 2018-10-26 14:15:57 +00:00
commit 2739a5fbe2
39 changed files with 641 additions and 189 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
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}

View File

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

View File

@ -0,0 +1,53 @@
# 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')},
),
]

View File

@ -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"),

View File

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

View File

@ -1,4 +1,5 @@
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"),
}
]
expected[0]["track"]["is_playable"] = False
assert response.status_code == 200
assert response.data["results"] == expected

View File

@ -464,24 +464,6 @@ def test_library_queryset_with_follows(factories):
assert l2._follows == [follow]
def test_annotate_duration(factories):
tf = factories["music.Upload"](duration=32)
track = models.Track.objects.annotate_duration().get(pk=tf.track.pk)
assert track.duration == 32
def test_annotate_file_data(factories):
tf = factories["music.Upload"](size=42, bitrate=55, mimetype="audio/ogg")
track = models.Track.objects.annotate_file_data().get(pk=tf.track.pk)
assert track.size == 42
assert track.bitrate == 55
assert track.mimetype == "audio/ogg"
@pytest.mark.parametrize(
"model,factory_args,namespace",
[

View File

@ -48,6 +48,7 @@ def test_artist_with_albums_serializer(factories, to_api_date):
def test_album_track_serializer(factories, to_api_date):
upload = factories["music.Upload"]()
track = upload.track
setattr(track, "playable_uploads", [upload])
expected = {
"id": track.id,
@ -56,7 +57,7 @@ def test_album_track_serializer(factories, to_api_date):
"mbid": str(track.mbid),
"title": track.title,
"position": track.position,
"is_playable": None,
"uploads": [serializers.TrackUploadSerializer(upload).data],
"creation_date": to_api_date(track.creation_date),
"listen_url": track.listen_url,
"duration": None,
@ -127,7 +128,7 @@ def test_album_serializer(factories, to_api_date):
"title": album.title,
"artist": serializers.ArtistSimpleSerializer(album.artist).data,
"creation_date": to_api_date(album.creation_date),
"is_playable": None,
"is_playable": False,
"cover": {
"original": album.cover.url,
"square_crop": album.cover.crop["400x400"].url,
@ -145,7 +146,7 @@ def test_album_serializer(factories, to_api_date):
def test_track_serializer(factories, to_api_date):
upload = factories["music.Upload"]()
track = upload.track
setattr(track, "playable_uploads", [upload])
expected = {
"id": track.id,
"artist": serializers.ArtistSimpleSerializer(track.artist).data,
@ -153,14 +154,10 @@ def test_track_serializer(factories, to_api_date):
"mbid": str(track.mbid),
"title": track.title,
"position": track.position,
"is_playable": None,
"uploads": [serializers.TrackUploadSerializer(upload).data],
"creation_date": to_api_date(track.creation_date),
"lyrics": track.get_lyrics_url(),
"listen_url": track.listen_url,
"duration": None,
"size": None,
"bitrate": None,
"mimetype": None,
}
serializer = serializers.TrackSerializer(track)
assert serializer.data == expected
@ -260,3 +257,20 @@ def test_manage_upload_action_relaunch_import(factories, mocker):
finished.refresh_from_db()
assert finished.import_status == "finished"
assert m.call_count == 3
def test_track_upload_serializer(factories):
upload = factories["music.Upload"]()
expected = {
"listen_url": upload.listen_url,
"uuid": str(upload.uuid),
"size": upload.size,
"bitrate": upload.bitrate,
"mimetype": upload.mimetype,
"extension": upload.extension,
"duration": upload.duration,
}
serializer = serializers.TrackUploadSerializer(upload)
assert serializer.data == expected

View File

@ -546,3 +546,20 @@ def test_scan_page_trigger_next_page_scan_skip_if_same(mocker, factories, r_mock
scan.refresh_from_db()
assert scan.status == "finished"
def test_clean_transcoding_cache(preferences, now, factories):
preferences["music__transcoding_cache_duration"] = 60
u1 = factories["music.UploadVersion"](
accessed_date=now - datetime.timedelta(minutes=61)
)
u2 = factories["music.UploadVersion"](
accessed_date=now - datetime.timedelta(minutes=59)
)
tasks.clean_transcoding_cache()
u2.refresh_from_db()
with pytest.raises(u1.__class__.DoesNotExist):
u1.refresh_from_db()

View File

@ -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__))
@ -38,13 +39,11 @@ def test_album_list_serializer(api_request, factories, logged_in_api_client):
).track
album = track.album
request = api_request.get("/")
qs = album.__class__.objects.all()
qs = album.__class__.objects.with_prefetched_tracks_and_playable_uploads(None)
serializer = serializers.AlbumSerializer(
qs, many=True, context={"request": request}
)
expected = {"count": 1, "next": None, "previous": None, "results": serializer.data}
expected["results"][0]["is_playable"] = True
expected["results"][0]["tracks"][0]["is_playable"] = True
url = reverse("api:v1:albums-list")
response = logged_in_api_client.get(url)
@ -57,12 +56,11 @@ def test_track_list_serializer(api_request, factories, logged_in_api_client):
library__privacy_level="everyone", import_status="finished"
).track
request = api_request.get("/")
qs = track.__class__.objects.all()
qs = track.__class__.objects.with_playable_uploads(None)
serializer = serializers.TrackSerializer(
qs, many=True, context={"request": request}
)
expected = {"count": 1, "next": None, "previous": None, "results": serializer.data}
expected["results"][0]["is_playable"] = True
url = reverse("api:v1:tracks-list")
response = logged_in_api_client.get(url)
@ -309,7 +307,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):

View File

@ -150,10 +150,6 @@ def test_playlist_playable_by_anonymous(privacy_level, expected, factories):
factories["music.Upload"](
track=track, library__privacy_level=privacy_level, import_status="finished"
)
queryset = playlist.__class__.objects.playable_by(None).annotate_playable_by_actor(
None
)
queryset = playlist.__class__.objects.playable_by(None).with_playable_plts(None)
match = playlist in list(queryset)
assert match is expected
if expected:
assert bool(queryset.first().is_playable_by_actor) is expected

View File

@ -145,7 +145,7 @@ def test_can_list_tracks_from_playlist(level, factories, logged_in_api_client):
url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk})
response = logged_in_api_client.get(url)
serialized_plt = serializers.PlaylistTrackSerializer(plt).data
serialized_plt["track"]["is_playable"] = False
assert response.data["count"] == 1
assert response.data["results"][0] == serialized_plt

View File

@ -4,9 +4,9 @@ import json
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework.response import Response
import funkwhale_api
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.subsonic import renderers, serializers
@ -199,11 +199,28 @@ def test_stream(f, db, logged_in_api_client, factories, mocker, queryset_equal_q
playable_by = mocker.spy(music_models.TrackQuerySet, "playable_by")
response = logged_in_api_client.get(url, {"f": f, "id": upload.track.pk})
mocked_serve.assert_called_once_with(upload=upload, user=logged_in_api_client.user)
mocked_serve.assert_called_once_with(
upload=upload, user=logged_in_api_client.user, format=None
)
assert response.status_code == 200
playable_by.assert_called_once_with(music_models.Track.objects.all(), None)
@pytest.mark.parametrize("format,expected", [("mp3", "mp3"), ("raw", None)])
def test_stream_format(format, expected, logged_in_api_client, factories, mocker):
url = reverse("api:subsonic-stream")
mocked_serve = mocker.patch.object(
music_views, "handle_serve", return_value=Response()
)
upload = factories["music.Upload"](playable=True)
response = logged_in_api_client.get(url, {"id": upload.track.pk, "format": format})
mocked_serve.assert_called_once_with(
upload=upload, user=logged_in_api_client.user, format=expected
)
assert response.status_code == 200
@pytest.mark.parametrize("f", ["xml", "json"])
def test_star(f, db, logged_in_api_client, factories):
url = reverse("api:subsonic-star")

View File

@ -134,7 +134,7 @@ def test_import_files_skip_if_path_already_imported(factories, mocker):
)
call_command(
"import_files", str(library.uuid), path, async=False, interactive=False
"import_files", str(library.uuid), path, async_=False, interactive=False
)
assert library.uploads.count() == 1

View File

@ -0,0 +1,13 @@
Audio transcoding is back! (#272)
Audio transcoding is back!
--------------------------
After removal of our first, buggy transcoding implementation, we're proud to announce
that this feature is back. It is enabled by default, and can be configured/disabled
in your instance settings!
This feature works in the browser, with federated/non-federated tracks and using Subsonic clients.
Transcoded tracks are generated on the fly, and cached for a configurable amount of time,
to reduce the load on the server.

View File

@ -0,0 +1 @@
The progress bar in the player now display loading state / buffer loading (#586)

View File

@ -79,10 +79,14 @@ export default {
return true
}
if (this.track) {
return this.track.is_playable
return this.track.uploads && this.track.uploads.length > 0
} else if (this.artist) {
return this.albums.filter((a) => {
return a.is_playable === true
}).length > 0
} else if (this.tracks) {
return this.tracks.filter((t) => {
return t.is_playable
return t.uploads && t.uploads.length > 0
}).length > 0
}
return false
@ -139,7 +143,7 @@ export default {
self.isLoading = false
}, 250)
return tracks.filter(e => {
return e.is_playable === true
return e.uploads && e.uploads.length > 0
})
})
},

View File

@ -4,6 +4,7 @@
<audio-track
ref="currentAudio"
v-if="currentTrack"
@errored="handleError"
:is-current="true"
:start-time="$store.state.player.currentTime"
:autoplay="$store.state.player.playing"
@ -41,21 +42,36 @@
</div>
</div>
</div>
<div class="progress-area" v-if="currentTrack">
<div class="progress-area" v-if="currentTrack && !errored">
<div class="ui grid">
<div class="left floated four wide column">
<p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p>
</div>
<div class="right floated four wide column">
<div v-if="!isLoadingAudio" class="right floated four wide column">
<p class="timer total">{{durationFormatted}}</p>
</div>
</div>
<div ref="progress" class="ui small orange inverted progress" @click="touchProgress">
<div
ref="progress"
:class="['ui', 'small', 'orange', 'inverted', {'indicating': isLoadingAudio}, 'progress']"
@click="touchProgress">
<div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div>
<div class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
</div>
</div>
<div class="ui small warning message" v-if="currentTrack && errored">
<div class="header">
<translate>We cannot load this track</translate>
</div>
<p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors">
<translate>The next track will play automatically in a few seconds...</translate>
<i class="loading spinner icon"></i>
</p>
<p>
<translate>You may have a connectivity issue.</translate>
</p>
</div>
<div class="two wide column controls ui grid">
<a
href
@ -295,15 +311,22 @@ export default {
}
let image = this.$refs.cover
this.ambiantColors = ColorThief.prototype.getPalette(image, 4).slice(0, 4)
},
handleError ({sound, error}) {
this.$store.commit('player/isLoadingAudio', false)
this.$store.dispatch('player/trackErrored')
}
},
computed: {
...mapState({
currentIndex: state => state.queue.currentIndex,
playing: state => state.player.playing,
isLoadingAudio: state => state.player.isLoadingAudio,
volume: state => state.player.volume,
looping: state => state.player.looping,
duration: state => state.player.duration,
bufferProgress: state => state.player.bufferProgress,
errored: state => state.player.errored,
queue: state => state.queue
}),
...mapGetters({
@ -522,4 +545,43 @@ export default {
margin: 0;
}
@keyframes MOVE-BG {
from {
transform: translateX(0px);
}
to {
transform: translateX(46px);
}
}
.indicating.progress {
overflow: hidden;
}
.ui.progress .bar {
transition: none;
}
.ui.inverted.progress .buffer.bar {
position: absolute;
background-color:rgba(255, 255, 255, 0.15);
}
.indicating.progress .bar {
left: -46px;
width: 200% !important;
color: grey;
background: repeating-linear-gradient(
-55deg,
grey 1px,
grey 10px,
transparent 10px,
transparent 20px,
) !important;
animation-name: MOVE-BG;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
</style>

View File

@ -44,11 +44,22 @@ export default {
}
},
onload: function () {
self.$store.commit('player/isLoadingAudio', false)
self.$store.commit('player/resetErrorCount')
self.$store.commit('player/errored', false)
self.$store.commit('player/duration', self.sound.duration())
}
let node = self.sound._sounds[0]._node;
node.addEventListener('progress', () => {
self.updateBuffer(node)
})
},
onloaderror: function (sound, error) {
console.log('Error while playing:', sound, error)
self.$emit('errored', {sound, error})
},
})
if (this.autoplay) {
self.$store.commit('player/isLoadingAudio', true)
this.sound.play()
this.$store.commit('player/playing', true)
this.observeProgress(true)
@ -67,14 +78,23 @@ export default {
looping: state => state.player.looping
}),
srcs: function () {
// let file = this.track.files[0]
// if (!file) {
// this.$store.dispatch('player/trackErrored')
// return []
// }
let sources = [
{type: 'mp3', url: this.$store.getters['instance/absoluteUrl'](this.track.listen_url)}
]
let sources = this.track.uploads.map(u => {
return {
type: u.extension,
url: this.$store.getters['instance/absoluteUrl'](u.listen_url),
}
})
// We always add a transcoded MP3 src at the end
// because transcoding is expensive, but we want browsers that do
// not support other codecs to be able to play it :)
sources.push({
type: 'mp3',
url: url.updateQueryString(
this.$store.getters['instance/absoluteUrl'](this.track.listen_url),
'to',
'mp3'
)
})
if (this.$store.state.auth.authenticated) {
// we need to send the token directly in url
// so authentication can be checked by the backend
@ -91,10 +111,40 @@ export default {
}
},
methods: {
updateBuffer (node) {
// from https://github.com/goldfire/howler.js/issues/752#issuecomment-372083163
let range = 0;
let bf = node.buffered;
let time = node.currentTime;
try {
while(!(bf.start(range) <= time && time <= bf.end(range))) {
range += 1;
}
} catch (IndexSizeError) {
return
}
let loadPercentage
let start = bf.start(range)
let end = bf.end(range)
if (range === 0) {
// easy case, no user-seek
let loadStartPercentage = start / node.duration;
let loadEndPercentage = end / node.duration;
loadPercentage = loadEndPercentage - loadStartPercentage;
} else {
let loaded = end - start
let remainingToLoad = node.duration - start
// user seeked a specific position in the audio, our progress must be
// computed based on the remaining portion of the track
loadPercentage = loaded / remainingToLoad;
}
this.$store.commit('player/bufferProgress', loadPercentage * 100)
},
updateProgress: function () {
this.isUpdatingTime = true
if (this.sound && this.sound.state() === 'loaded') {
this.$store.dispatch('player/updateProgress', this.sound.seek())
this.updateBuffer(this.sound._sounds[0]._node)
}
},
observeProgress: function (enable) {

View File

@ -34,8 +34,8 @@
{{ track.album.title }}
</router-link>
</td>
<td colspan="4" v-if="track.duration">
{{ time.parse(track.duration) }}
<td colspan="4" v-if="track.uploads && track.uploads.length > 0">
{{ time.parse(track.uploads[0].duration) }}
</td>
<td colspan="4" v-else>
<translate>N/A</translate>

View File

@ -44,13 +44,13 @@
<i class="external icon"></i>
<translate>View on MusicBrainz</translate>
</a>
<a v-if="track.is_playable" :href="downloadUrl" target="_blank" class="ui button">
<a v-if="upload" :href="downloadUrl" target="_blank" class="ui button">
<i class="download icon"></i>
<translate>Download</translate>
</a>
</div>
</div>
<div class="ui vertical stripe center aligned segment">
<div class="ui vertical stripe center aligned segment" v-if="upload">
<h2 class="ui header"><translate>Track information</translate></h2>
<table class="ui very basic collapsing celled center aligned table">
<tbody>
@ -58,8 +58,8 @@
<td>
<translate>Duration</translate>
</td>
<td v-if="track.duration">
{{ time.parse(track.duration) }}
<td v-if="upload.duration">
{{ time.parse(upload.duration) }}
</td>
<td v-else>
<translate>N/A</translate>
@ -69,8 +69,8 @@
<td>
<translate>Size</translate>
</td>
<td v-if="track.size">
{{ track.size | humanSize }}
<td v-if="upload.size">
{{ upload.size | humanSize }}
</td>
<td v-else>
<translate>N/A</translate>
@ -80,8 +80,8 @@
<td>
<translate>Bitrate</translate>
</td>
<td v-if="track.bitrate">
{{ track.bitrate | humanSize }}/s
<td v-if="upload.bitrate">
{{ upload.bitrate | humanSize }}/s
</td>
<td v-else>
<translate>N/A</translate>
@ -91,8 +91,8 @@
<td>
<translate>Type</translate>
</td>
<td v-if="track.mimetype">
{{ track.mimetype }}
<td v-if="upload.extension">
{{ upload.extension }}
</td>
<td v-else>
<translate>N/A</translate>
@ -195,6 +195,11 @@ export default {
title: this.$gettext('Track')
}
},
upload () {
if (this.track.uploads) {
return this.track.uploads[0]
}
},
wikipediaUrl () {
return 'https://en.wikipedia.org/w/index.php?search=' + encodeURI(this.track.title + ' ' + this.track.artist.name)
},
@ -204,7 +209,7 @@ export default {
}
},
downloadUrl () {
let u = this.$store.getters['instance/absoluteUrl'](this.track.listen_url)
let u = this.$store.getters['instance/absoluteUrl'](this.upload.listen_url)
if (this.$store.state.auth.authenticated) {
u = url.updateQueryString(u, 'jwt', encodeURI(this.$store.state.auth.token))
}

View File

@ -79,6 +79,7 @@ export default new Vuex.Store({
id: track.id,
title: track.title,
mbid: track.mbid,
uploads: track.uploads,
listen_url: track.listen_url,
album: {
id: track.album.id,

View File

@ -8,11 +8,13 @@ export default {
maxConsecutiveErrors: 5,
errorCount: 0,
playing: false,
isLoadingAudio: false,
volume: 0.5,
tempVolume: 0.5,
duration: 0,
currentTime: 0,
errored: false,
bufferProgress: 0,
looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue
},
mutations: {
@ -59,12 +61,18 @@ export default {
playing (state, value) {
state.playing = value
},
bufferProgress (state, value) {
state.bufferProgress = value
},
toggleLooping (state) {
if (state.looping > 1) {
state.looping = 0
} else {
state.looping += 1
}
},
isLoadingAudio (state, value) {
state.isLoadingAudio = value
}
},
getters: {
@ -87,10 +95,19 @@ export default {
incrementVolume ({commit, state}, value) {
commit('volume', state.volume + value)
},
stop (context) {
stop ({commit}) {
commit('errored', false)
commit('resetErrorCount')
},
togglePlay ({commit, state}) {
togglePlay ({commit, state, dispatch}) {
commit('playing', !state.playing)
if (state.errored && state.errorCount < state.maxConsecutiveErrors) {
setTimeout(() => {
if (state.playing) {
dispatch('queue/next', null, {root: true})
}
}, 3000)
}
},
trackListened ({commit, rootState}, track) {
if (!rootState.auth.authenticated) {
@ -113,7 +130,13 @@ export default {
trackErrored ({commit, dispatch, state}) {
commit('errored', true)
commit('incrementErrorCount')
dispatch('queue/next', null, {root: true})
if (state.errorCount < state.maxConsecutiveErrors) {
setTimeout(() => {
if (state.playing) {
dispatch('queue/next', null, {root: true})
}
}, 3000)
}
},
updateProgress ({commit}, t) {
commit('currentTime', t)

View File

@ -142,7 +142,6 @@ export default {
commit('ended', false)
commit('player/currentTime', 0, {root: true})
commit('player/playing', true, {root: true})
commit('player/errored', false, {root: true})
commit('currentIndex', index)
if (state.tracks.length - index <= 2 && rootState.radios.running) {
dispatch('radios/populateQueue', null, {root: true})

View File

@ -79,6 +79,7 @@ export default {
// somehow, extraction fails if in the return block directly
let instanceLabel = this.$gettext('Instance information')
let usersLabel = this.$gettext('Users')
let musicLabel = this.$gettext('Music')
let playlistsLabel = this.$gettext('Playlists')
let federationLabel = this.$gettext('Federation')
let subsonicLabel = this.$gettext('Subsonic')
@ -104,6 +105,14 @@ export default {
'users__upload_quota'
]
},
{
label: musicLabel,
id: 'music',
settings: [
'music__transcoding_enabled',
'music__transcoding_cache_duration',
]
},
{
label: playlistsLabel,
id: 'playlists',

View File

@ -267,7 +267,6 @@ describe('store/queue', () => {
{ type: 'ended', payload: false },
{ type: 'player/currentTime', payload: 0, options: {root: true} },
{ type: 'player/playing', payload: true, options: {root: true} },
{ type: 'player/errored', payload: false, options: {root: true} },
{ type: 'currentIndex', payload: 1 }
]
})
@ -281,7 +280,6 @@ describe('store/queue', () => {
{ type: 'ended', payload: false },
{ type: 'player/currentTime', payload: 0, options: {root: true} },
{ type: 'player/playing', payload: true, options: {root: true} },
{ type: 'player/errored', payload: false, options: {root: true} },
{ type: 'currentIndex', payload: 1 }
]
})
@ -295,7 +293,6 @@ describe('store/queue', () => {
{ type: 'ended', payload: false },
{ type: 'player/currentTime', payload: 0, options: {root: true} },
{ type: 'player/playing', payload: true, options: {root: true} },
{ type: 'player/errored', payload: false, options: {root: true} },
{ type: 'currentIndex', payload: 1 }
],
expectedActions: [