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:
commit
2739a5fbe2
|
@ -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)
|
||||
|
|
|
@ -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}
|
|
@ -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")
|
||||
|
|
|
@ -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')},
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"),
|
||||
}
|
||||
]
|
||||
expected[0]["track"]["is_playable"] = False
|
||||
assert response.status_code == 200
|
||||
assert response.data["results"] == expected
|
||||
|
||||
|
|
|
@ -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",
|
||||
[
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
The progress bar in the player now display loading state / buffer loading (#586)
|
|
@ -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
|
||||
})
|
||||
})
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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: [
|
||||
|
|
Loading…
Reference in New Issue