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", "task": "federation.clean_music_cache",
"schedule": crontab(hour="*/2"), "schedule": crontab(hour="*/2"),
"options": {"expires": 60 * 2}, "options": {"expires": 60 * 2},
} },
"music.clean_transcoding_cache": {
"task": "music.clean_transcoding_cache",
"schedule": crontab(hour="*"),
"options": {"expires": 60 * 2},
},
} }
JWT_AUTH = { JWT_AUTH = {

View File

@ -3,7 +3,7 @@ import re
from django.db.models import Q from django.db.models import Q
QUERY_REGEX = re.compile('(((?P<key>\w+):)?(?P<value>"[^"]+"|[\S]+))') QUERY_REGEX = re.compile(r'(((?P<key>\w+):)?(?P<value>"[^"]+"|[\S]+))')
def parse_query(query): def parse_query(query):

View File

@ -51,7 +51,7 @@ class TrackFavoriteViewSet(
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(self.request.user, "user__privacy_level")
) )
tracks = Track.objects.annotate_playable_by_actor( tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request) music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist") ).select_related("artist", "album__artist")
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks)) queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))

View File

@ -41,7 +41,7 @@ class ListeningViewSet(
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(self.request.user, "user__privacy_level")
) )
tracks = Track.objects.annotate_playable_by_actor( tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request) music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist") ).select_related("artist", "album__artist")
return queryset.prefetch_related(Prefetch("track", queryset=tracks)) return queryset.prefetch_related(Prefetch("track", queryset=tracks))

View File

@ -78,6 +78,28 @@ class UploadAdmin(admin.ModelAdmin):
list_filter = ["mimetype", "import_status", "library__privacy_level"] list_filter = ["mimetype", "import_status", "library__privacy_level"]
@admin.register(models.UploadVersion)
class UploadVersionAdmin(admin.ModelAdmin):
list_display = [
"upload",
"audio_file",
"mimetype",
"size",
"bitrate",
"creation_date",
"accessed_date",
]
list_select_related = ["upload"]
search_fields = [
"upload__source",
"upload__acoustid_track_id",
"upload__track__title",
"upload__track__album__title",
"upload__track__artist__name",
]
list_filter = ["mimetype"]
def launch_scan(modeladmin, request, queryset): def launch_scan(modeladmin, request, queryset):
for library in queryset: for library in queryset:
library.schedule_scan(actor=request.user.actor, force=True) library.schedule_scan(actor=request.user.actor, force=True)

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 @registry.register
class WorkFactory(factory.django.DjangoModelFactory): class WorkFactory(factory.django.DjangoModelFactory):
mbid = factory.Faker("uuid4") 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.contrib.postgres.fields import JSONField
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models, transaction
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@ -124,8 +124,8 @@ class ArtistQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor): def annotate_playable_by_actor(self, actor):
tracks = ( tracks = (
Track.objects.playable_by(actor) Upload.objects.playable_by(actor)
.filter(artist=models.OuterRef("id")) .filter(track__artist=models.OuterRef("id"))
.order_by("id") .order_by("id")
.values("id")[:1] .values("id")[:1]
) )
@ -192,8 +192,8 @@ class AlbumQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor): def annotate_playable_by_actor(self, actor):
tracks = ( tracks = (
Track.objects.playable_by(actor) Upload.objects.playable_by(actor)
.filter(album=models.OuterRef("id")) .filter(track__album=models.OuterRef("id"))
.order_by("id") .order_by("id")
.values("id")[:1] .values("id")[:1]
) )
@ -207,6 +207,10 @@ class AlbumQuerySet(models.QuerySet):
else: else:
return self.exclude(tracks__in=tracks).distinct() return self.exclude(tracks__in=tracks).distinct()
def with_prefetched_tracks_and_playable_uploads(self, actor):
tracks = Track.objects.with_playable_uploads(actor)
return self.prefetch_related(models.Prefetch("tracks", queryset=tracks))
class Album(APIModelMixin): class Album(APIModelMixin):
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
@ -403,18 +407,10 @@ class TrackQuerySet(models.QuerySet):
else: else:
return self.exclude(uploads__in=files).distinct() return self.exclude(uploads__in=files).distinct()
def annotate_duration(self): def with_playable_uploads(self, actor):
first_upload = Upload.objects.filter(track=models.OuterRef("pk")).order_by("pk") uploads = Upload.objects.playable_by(actor).select_related("track")
return self.annotate( return self.prefetch_related(
duration=models.Subquery(first_upload.values("duration")[:1]) models.Prefetch("uploads", queryset=uploads, to_attr="playable_uploads")
)
def annotate_file_data(self):
first_upload = Upload.objects.filter(track=models.OuterRef("pk")).order_by("pk")
return self.annotate(
bitrate=models.Subquery(first_upload.values("bitrate")[:1]),
size=models.Subquery(first_upload.values("size")[:1]),
mimetype=models.Subquery(first_upload.values("mimetype")[:1]),
) )
@ -578,6 +574,9 @@ TRACK_FILE_IMPORT_STATUS_CHOICES = (
def get_file_path(instance, filename): def get_file_path(instance, filename):
if isinstance(instance, UploadVersion):
return common_utils.ChunkedPath("transcoded")(instance, filename)
if instance.library.actor.get_user(): if instance.library.actor.get_user():
return common_utils.ChunkedPath("tracks")(instance, filename) return common_utils.ChunkedPath("tracks")(instance, filename)
else: else:
@ -741,6 +740,61 @@ class Upload(models.Model):
def listen_url(self): def listen_url(self):
return self.track.listen_url + "?upload={}".format(self.uuid) return self.track.listen_url + "?upload={}".format(self.uuid)
def get_transcoded_version(self, format):
mimetype = utils.EXTENSION_TO_MIMETYPE[format]
existing_versions = list(self.versions.filter(mimetype=mimetype))
if existing_versions:
# we found an existing version, no need to transcode again
return existing_versions[0]
return self.create_transcoded_version(mimetype, format)
@transaction.atomic
def create_transcoded_version(self, mimetype, format):
# we create the version with an empty file, then
# we'll write to it
f = ContentFile(b"")
version = self.versions.create(
mimetype=mimetype, bitrate=self.bitrate or 128000, size=0
)
# we keep the same name, but we update the extension
new_name = os.path.splitext(os.path.basename(self.audio_file.name))[
0
] + ".{}".format(format)
version.audio_file.save(new_name, f)
utils.transcode_file(
input=self.audio_file,
output=version.audio_file,
input_format=utils.MIMETYPE_TO_EXTENSION[self.mimetype],
output_format=utils.MIMETYPE_TO_EXTENSION[mimetype],
)
version.size = version.audio_file.size
version.save(update_fields=["size"])
return version
MIMETYPE_CHOICES = [(mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE]
class UploadVersion(models.Model):
upload = models.ForeignKey(
Upload, related_name="versions", on_delete=models.CASCADE
)
mimetype = models.CharField(max_length=50, choices=MIMETYPE_CHOICES)
creation_date = models.DateTimeField(default=timezone.now)
accessed_date = models.DateTimeField(null=True, blank=True)
audio_file = models.FileField(upload_to=get_file_path, max_length=255)
bitrate = models.PositiveIntegerField()
size = models.IntegerField()
class Meta:
unique_together = ("upload", "mimetype", "bitrate")
@property
def filename(self):
return self.upload.filename
IMPORT_STATUS_CHOICES = ( IMPORT_STATUS_CHOICES = (
("pending", "Pending"), ("pending", "Pending"),

View File

@ -59,7 +59,7 @@ class ArtistSimpleSerializer(serializers.ModelSerializer):
class AlbumTrackSerializer(serializers.ModelSerializer): class AlbumTrackSerializer(serializers.ModelSerializer):
artist = ArtistSimpleSerializer(read_only=True) artist = ArtistSimpleSerializer(read_only=True)
is_playable = serializers.SerializerMethodField() uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField()
duration = serializers.SerializerMethodField() duration = serializers.SerializerMethodField()
@ -73,16 +73,14 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
"artist", "artist",
"creation_date", "creation_date",
"position", "position",
"is_playable", "uploads",
"listen_url", "listen_url",
"duration", "duration",
) )
def get_is_playable(self, obj): def get_uploads(self, obj):
try: uploads = getattr(obj, "playable_uploads", [])
return bool(obj.is_playable_by_actor) return TrackUploadSerializer(uploads, many=True).data
except AttributeError:
return None
def get_listen_url(self, obj): def get_listen_url(self, obj):
return obj.listen_url return obj.listen_url
@ -123,7 +121,9 @@ class AlbumSerializer(serializers.ModelSerializer):
def get_is_playable(self, obj): def get_is_playable(self, obj):
try: try:
return any([bool(t.is_playable_by_actor) for t in obj.tracks.all()]) return any(
[bool(getattr(t, "playable_uploads", [])) for t in obj.tracks.all()]
)
except AttributeError: except AttributeError:
return None return None
@ -145,16 +145,26 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
) )
class TrackUploadSerializer(serializers.ModelSerializer):
class Meta:
model = models.Upload
fields = (
"uuid",
"listen_url",
"size",
"duration",
"bitrate",
"mimetype",
"extension",
)
class TrackSerializer(serializers.ModelSerializer): class TrackSerializer(serializers.ModelSerializer):
artist = ArtistSimpleSerializer(read_only=True) artist = ArtistSimpleSerializer(read_only=True)
album = TrackAlbumSerializer(read_only=True) album = TrackAlbumSerializer(read_only=True)
lyrics = serializers.SerializerMethodField() lyrics = serializers.SerializerMethodField()
is_playable = serializers.SerializerMethodField() uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField() listen_url = serializers.SerializerMethodField()
duration = serializers.SerializerMethodField()
bitrate = serializers.SerializerMethodField()
size = serializers.SerializerMethodField()
mimetype = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Track model = models.Track
@ -167,12 +177,8 @@ class TrackSerializer(serializers.ModelSerializer):
"creation_date", "creation_date",
"position", "position",
"lyrics", "lyrics",
"is_playable", "uploads",
"listen_url", "listen_url",
"duration",
"bitrate",
"size",
"mimetype",
) )
def get_lyrics(self, obj): def get_lyrics(self, obj):
@ -181,35 +187,9 @@ class TrackSerializer(serializers.ModelSerializer):
def get_listen_url(self, obj): def get_listen_url(self, obj):
return obj.listen_url return obj.listen_url
def get_is_playable(self, obj): def get_uploads(self, obj):
try: uploads = getattr(obj, "playable_uploads", [])
return bool(obj.is_playable_by_actor) return TrackUploadSerializer(uploads, many=True).data
except AttributeError:
return None
def get_duration(self, obj):
try:
return obj.duration
except AttributeError:
return None
def get_bitrate(self, obj):
try:
return obj.bitrate
except AttributeError:
return None
def get_size(self, obj):
try:
return obj.size
except AttributeError:
return None
def get_mimetype(self, obj):
try:
return obj.mimetype
except AttributeError:
return None
class LibraryForOwnerSerializer(serializers.ModelSerializer): class LibraryForOwnerSerializer(serializers.ModelSerializer):

View File

@ -1,4 +1,5 @@
import collections import collections
import datetime
import logging import logging
import os import os
@ -10,7 +11,7 @@ from django.dispatch import receiver
from musicbrainzngs import ResponseError from musicbrainzngs import ResponseError
from requests.exceptions import RequestException from requests.exceptions import RequestException
from funkwhale_api.common import channels from funkwhale_api.common import channels, preferences
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
from funkwhale_api.federation import library as lb from funkwhale_api.federation import library as lb
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
@ -526,3 +527,19 @@ def broadcast_import_status_update_to_owner(old_status, new_status, upload, **kw
}, },
}, },
) )
@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 magic
import mutagen import mutagen
import pydub
from funkwhale_api.common.search import normalize_query, get_query # noqa from funkwhale_api.common.search import normalize_query, get_query # noqa
@ -68,3 +69,10 @@ def get_actor_from_request(request):
actor = request.user.actor actor = request.user.actor
return actor return actor
def transcode_file(input, output, input_format, output_format, **kwargs):
with input.open("rb"):
audio = pydub.AudioSegment.from_file(input, format=input_format)
with output.open("wb"):
return audio.export(output, format=output_format, **kwargs)

View File

@ -15,8 +15,9 @@ from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response from rest_framework.response import Response
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import permissions as common_permissions from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation.authentication import SignatureAuthentication from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
@ -92,17 +93,9 @@ class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
tracks = models.Track.objects.annotate_playable_by_actor( tracks = models.Track.objects.select_related("artist").with_playable_uploads(
utils.get_actor_from_request(self.request) utils.get_actor_from_request(self.request)
).select_related("artist") )
if (
hasattr(self, "kwargs")
and self.kwargs
and self.request.method.lower() == "get"
):
# we are detailing a single album, so we can add the overhead
# to fetch additional data
tracks = tracks.annotate_duration()
qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks)) qs = queryset.prefetch_related(Prefetch("tracks", queryset=tracks))
return qs.distinct() return qs.distinct()
@ -193,18 +186,10 @@ class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
if user.is_authenticated and filter_favorites == "true": if user.is_authenticated and filter_favorites == "true":
queryset = queryset.filter(track_favorites__user=user) queryset = queryset.filter(track_favorites__user=user)
queryset = queryset.annotate_playable_by_actor( queryset = queryset.with_playable_uploads(
utils.get_actor_from_request(self.request) utils.get_actor_from_request(self.request)
).annotate_duration() )
if ( return queryset
hasattr(self, "kwargs")
and self.kwargs
and self.request.method.lower() == "get"
):
# we are detailing a single track, so we can add the overhead
# to fetch additional data
queryset = queryset.annotate_file_data()
return queryset.distinct()
@detail_route(methods=["get"]) @detail_route(methods=["get"])
@transaction.non_atomic_requests @transaction.non_atomic_requests
@ -267,12 +252,31 @@ def get_file_path(audio_file):
return path.encode("utf-8") return path.encode("utf-8")
def handle_serve(upload, user): def should_transcode(upload, format):
if not preferences.get("music__transcoding_enabled"):
return False
if format is None:
return False
if format not in utils.EXTENSION_TO_MIMETYPE:
# format should match supported formats
return False
if upload.mimetype is None:
# upload should have a mimetype, otherwise we cannot transcode
return False
if upload.mimetype == utils.EXTENSION_TO_MIMETYPE[format]:
# requested format sould be different than upload mimetype, otherwise
# there is no need to transcode
return False
return True
def handle_serve(upload, user, format=None):
f = upload f = upload
# we update the accessed_date # we update the accessed_date
f.accessed_date = timezone.now() now = timezone.now()
f.save(update_fields=["accessed_date"]) upload.accessed_date = now
upload.save(update_fields=["accessed_date"])
f = upload
if f.audio_file: if f.audio_file:
file_path = get_file_path(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://"): elif f.source and f.source.startswith("file://"):
file_path = get_file_path(f.source.replace("file://", "", 1)) file_path = get_file_path(f.source.replace("file://", "", 1))
mt = f.mimetype 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: if mt:
response = Response(content_type=mt) response = Response(content_type=mt)
else: else:
@ -337,7 +349,8 @@ class ListenViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
if not upload: if not upload:
return Response(status=404) 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( class UploadViewSet(

View File

@ -38,15 +38,14 @@ class PlaylistQuerySet(models.QuerySet):
) )
return self.prefetch_related(plt_prefetch) return self.prefetch_related(plt_prefetch)
def annotate_playable_by_actor(self, actor): def with_playable_plts(self, actor):
plts = ( return self.prefetch_related(
PlaylistTrack.objects.playable_by(actor) models.Prefetch(
.filter(playlist=models.OuterRef("id")) "playlist_tracks",
.order_by("id") queryset=PlaylistTrack.objects.playable_by(actor),
.values("id")[:1] to_attr="playable_plts",
)
) )
subquery = models.Subquery(plts)
return self.annotate(is_playable_by_actor=subquery)
def playable_by(self, actor, include=True): def playable_by(self, actor, include=True):
plts = PlaylistTrack.objects.playable_by(actor, include) plts = PlaylistTrack.objects.playable_by(actor, include)
@ -148,7 +147,7 @@ class Playlist(models.Model):
class PlaylistTrackQuerySet(models.QuerySet): class PlaylistTrackQuerySet(models.QuerySet):
def for_nested_serialization(self, actor=None): 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") tracks = tracks.select_related("artist", "album__artist")
return self.prefetch_related( return self.prefetch_related(
models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track") models.Prefetch("track", queryset=tracks, to_attr="_prefetched_track")
@ -156,8 +155,8 @@ class PlaylistTrackQuerySet(models.QuerySet):
def annotate_playable_by_actor(self, actor): def annotate_playable_by_actor(self, actor):
tracks = ( tracks = (
music_models.Track.objects.playable_by(actor) music_models.Upload.objects.playable_by(actor)
.filter(pk=models.OuterRef("track")) .filter(track__pk=models.OuterRef("track"))
.order_by("id") .order_by("id")
.values("id")[:1] .values("id")[:1]
) )

View File

@ -93,7 +93,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
def get_is_playable(self, obj): def get_is_playable(self, obj):
try: try:
return bool(obj.is_playable_by_actor) return bool(obj.playable_plts)
except AttributeError: except AttributeError:
return None return None

View File

@ -78,7 +78,7 @@ class PlaylistViewSet(
def get_queryset(self): def get_queryset(self):
return self.queryset.filter( return self.queryset.filter(
fields.privacy_level_query(self.request.user) 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): def perform_create(self, serializer):
return serializer.save( return serializer.save(

View File

@ -193,12 +193,17 @@ class SubsonicViewSet(viewsets.GenericViewSet):
@list_route(methods=["get", "post"], url_name="stream", url_path="stream") @list_route(methods=["get", "post"], url_name="stream", url_path="stream")
@find_object(music_models.Track.objects.all(), filter_playable=True) @find_object(music_models.Track.objects.all(), filter_playable=True)
def stream(self, request, *args, **kwargs): def stream(self, request, *args, **kwargs):
data = request.GET or request.POST
track = kwargs.pop("obj") track = kwargs.pop("obj")
queryset = track.uploads.select_related("track__album__artist", "track__artist") queryset = track.uploads.select_related("track__album__artist", "track__artist")
upload = queryset.first() upload = queryset.first()
if not upload: if not upload:
return response.Response(status=404) 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") @list_route(methods=["get", "post"], url_name="star", url_path="star")
@find_object(music_models.Track.objects.all()) @find_object(music_models.Track.objects.all())

View File

@ -69,3 +69,4 @@ django-cleanup==2.1.0
# for LDAP authentication # for LDAP authentication
python-ldap==3.1.0 python-ldap==3.1.0
django-auth-ldap==1.7.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"), "creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"),
} }
] ]
expected[0]["track"]["is_playable"] = False
assert response.status_code == 200 assert response.status_code == 200
assert response.data["results"] == expected assert response.data["results"] == expected

View File

@ -464,24 +464,6 @@ def test_library_queryset_with_follows(factories):
assert l2._follows == [follow] 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( @pytest.mark.parametrize(
"model,factory_args,namespace", "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): def test_album_track_serializer(factories, to_api_date):
upload = factories["music.Upload"]() upload = factories["music.Upload"]()
track = upload.track track = upload.track
setattr(track, "playable_uploads", [upload])
expected = { expected = {
"id": track.id, "id": track.id,
@ -56,7 +57,7 @@ def test_album_track_serializer(factories, to_api_date):
"mbid": str(track.mbid), "mbid": str(track.mbid),
"title": track.title, "title": track.title,
"position": track.position, "position": track.position,
"is_playable": None, "uploads": [serializers.TrackUploadSerializer(upload).data],
"creation_date": to_api_date(track.creation_date), "creation_date": to_api_date(track.creation_date),
"listen_url": track.listen_url, "listen_url": track.listen_url,
"duration": None, "duration": None,
@ -127,7 +128,7 @@ def test_album_serializer(factories, to_api_date):
"title": album.title, "title": album.title,
"artist": serializers.ArtistSimpleSerializer(album.artist).data, "artist": serializers.ArtistSimpleSerializer(album.artist).data,
"creation_date": to_api_date(album.creation_date), "creation_date": to_api_date(album.creation_date),
"is_playable": None, "is_playable": False,
"cover": { "cover": {
"original": album.cover.url, "original": album.cover.url,
"square_crop": album.cover.crop["400x400"].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): def test_track_serializer(factories, to_api_date):
upload = factories["music.Upload"]() upload = factories["music.Upload"]()
track = upload.track track = upload.track
setattr(track, "playable_uploads", [upload])
expected = { expected = {
"id": track.id, "id": track.id,
"artist": serializers.ArtistSimpleSerializer(track.artist).data, "artist": serializers.ArtistSimpleSerializer(track.artist).data,
@ -153,14 +154,10 @@ def test_track_serializer(factories, to_api_date):
"mbid": str(track.mbid), "mbid": str(track.mbid),
"title": track.title, "title": track.title,
"position": track.position, "position": track.position,
"is_playable": None, "uploads": [serializers.TrackUploadSerializer(upload).data],
"creation_date": to_api_date(track.creation_date), "creation_date": to_api_date(track.creation_date),
"lyrics": track.get_lyrics_url(), "lyrics": track.get_lyrics_url(),
"listen_url": track.listen_url, "listen_url": track.listen_url,
"duration": None,
"size": None,
"bitrate": None,
"mimetype": None,
} }
serializer = serializers.TrackSerializer(track) serializer = serializers.TrackSerializer(track)
assert serializer.data == expected assert serializer.data == expected
@ -260,3 +257,20 @@ def test_manage_upload_action_relaunch_import(factories, mocker):
finished.refresh_from_db() finished.refresh_from_db()
assert finished.import_status == "finished" assert finished.import_status == "finished"
assert m.call_count == 3 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() scan.refresh_from_db()
assert scan.status == "finished" 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 io
import magic
import os import os
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 from funkwhale_api.federation import api_serializers as federation_api_serializers
DATA_DIR = os.path.dirname(os.path.abspath(__file__)) 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 ).track
album = track.album album = track.album
request = api_request.get("/") request = api_request.get("/")
qs = album.__class__.objects.all() qs = album.__class__.objects.with_prefetched_tracks_and_playable_uploads(None)
serializer = serializers.AlbumSerializer( serializer = serializers.AlbumSerializer(
qs, many=True, context={"request": request} qs, many=True, context={"request": request}
) )
expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} 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") url = reverse("api:v1:albums-list")
response = logged_in_api_client.get(url) 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" library__privacy_level="everyone", import_status="finished"
).track ).track
request = api_request.get("/") request = api_request.get("/")
qs = track.__class__.objects.all() qs = track.__class__.objects.with_playable_uploads(None)
serializer = serializers.TrackSerializer( serializer = serializers.TrackSerializer(
qs, many=True, context={"request": request} qs, many=True, context={"request": request}
) )
expected = {"count": 1, "next": None, "previous": None, "results": serializer.data} expected = {"count": 1, "next": None, "previous": None, "results": serializer.data}
expected["results"][0]["is_playable"] = True
url = reverse("api:v1:tracks-list") url = reverse("api:v1:tracks-list")
response = logged_in_api_client.get(url) 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}) response = logged_in_api_client.get(url, {"upload": upload2.uuid})
assert response.status_code == 200 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): 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"]( factories["music.Upload"](
track=track, library__privacy_level=privacy_level, import_status="finished" track=track, library__privacy_level=privacy_level, import_status="finished"
) )
queryset = playlist.__class__.objects.playable_by(None).annotate_playable_by_actor( queryset = playlist.__class__.objects.playable_by(None).with_playable_plts(None)
None
)
match = playlist in list(queryset) match = playlist in list(queryset)
assert match is expected 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}) url = reverse("api:v1:playlists-tracks", kwargs={"pk": plt.playlist.pk})
response = logged_in_api_client.get(url) response = logged_in_api_client.get(url)
serialized_plt = serializers.PlaylistTrackSerializer(plt).data serialized_plt = serializers.PlaylistTrackSerializer(plt).data
serialized_plt["track"]["is_playable"] = False
assert response.data["count"] == 1 assert response.data["count"] == 1
assert response.data["results"][0] == serialized_plt assert response.data["results"][0] == serialized_plt

View File

@ -4,9 +4,9 @@ import json
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.response import Response
import funkwhale_api import funkwhale_api
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views from funkwhale_api.music import views as music_views
from funkwhale_api.subsonic import renderers, serializers 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") playable_by = mocker.spy(music_models.TrackQuerySet, "playable_by")
response = logged_in_api_client.get(url, {"f": f, "id": upload.track.pk}) 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 assert response.status_code == 200
playable_by.assert_called_once_with(music_models.Track.objects.all(), None) 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"]) @pytest.mark.parametrize("f", ["xml", "json"])
def test_star(f, db, logged_in_api_client, factories): def test_star(f, db, logged_in_api_client, factories):
url = reverse("api:subsonic-star") url = reverse("api:subsonic-star")

View File

@ -134,7 +134,7 @@ def test_import_files_skip_if_path_already_imported(factories, mocker):
) )
call_command( 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 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 return true
} }
if (this.track) { 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) { } else if (this.tracks) {
return this.tracks.filter((t) => { return this.tracks.filter((t) => {
return t.is_playable return t.uploads && t.uploads.length > 0
}).length > 0 }).length > 0
} }
return false return false
@ -139,7 +143,7 @@ export default {
self.isLoading = false self.isLoading = false
}, 250) }, 250)
return tracks.filter(e => { return tracks.filter(e => {
return e.is_playable === true return e.uploads && e.uploads.length > 0
}) })
}) })
}, },

View File

@ -4,6 +4,7 @@
<audio-track <audio-track
ref="currentAudio" ref="currentAudio"
v-if="currentTrack" v-if="currentTrack"
@errored="handleError"
:is-current="true" :is-current="true"
:start-time="$store.state.player.currentTime" :start-time="$store.state.player.currentTime"
:autoplay="$store.state.player.playing" :autoplay="$store.state.player.playing"
@ -41,21 +42,36 @@
</div> </div>
</div> </div>
</div> </div>
<div class="progress-area" v-if="currentTrack"> <div class="progress-area" v-if="currentTrack && !errored">
<div class="ui grid"> <div class="ui grid">
<div class="left floated four wide column"> <div class="left floated four wide column">
<p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p> <p class="timer start" @click="updateProgress(0)">{{currentTimeFormatted}}</p>
</div> </div>
<div class="right floated four wide column"> <div v-if="!isLoadingAudio" class="right floated four wide column">
<p class="timer total">{{durationFormatted}}</p> <p class="timer total">{{durationFormatted}}</p>
</div> </div>
</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 class="bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
</div> </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"> <div class="two wide column controls ui grid">
<a <a
href href
@ -295,15 +311,22 @@ export default {
} }
let image = this.$refs.cover let image = this.$refs.cover
this.ambiantColors = ColorThief.prototype.getPalette(image, 4).slice(0, 4) 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: { computed: {
...mapState({ ...mapState({
currentIndex: state => state.queue.currentIndex, currentIndex: state => state.queue.currentIndex,
playing: state => state.player.playing, playing: state => state.player.playing,
isLoadingAudio: state => state.player.isLoadingAudio,
volume: state => state.player.volume, volume: state => state.player.volume,
looping: state => state.player.looping, looping: state => state.player.looping,
duration: state => state.player.duration, duration: state => state.player.duration,
bufferProgress: state => state.player.bufferProgress,
errored: state => state.player.errored,
queue: state => state.queue queue: state => state.queue
}), }),
...mapGetters({ ...mapGetters({
@ -522,4 +545,43 @@ export default {
margin: 0; 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> </style>

View File

@ -44,11 +44,22 @@ export default {
} }
}, },
onload: function () { onload: function () {
self.$store.commit('player/isLoadingAudio', false)
self.$store.commit('player/resetErrorCount') self.$store.commit('player/resetErrorCount')
self.$store.commit('player/errored', false)
self.$store.commit('player/duration', self.sound.duration()) 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) { if (this.autoplay) {
self.$store.commit('player/isLoadingAudio', true)
this.sound.play() this.sound.play()
this.$store.commit('player/playing', true) this.$store.commit('player/playing', true)
this.observeProgress(true) this.observeProgress(true)
@ -67,14 +78,23 @@ export default {
looping: state => state.player.looping looping: state => state.player.looping
}), }),
srcs: function () { srcs: function () {
// let file = this.track.files[0] let sources = this.track.uploads.map(u => {
// if (!file) { return {
// this.$store.dispatch('player/trackErrored') type: u.extension,
// return [] url: this.$store.getters['instance/absoluteUrl'](u.listen_url),
// } }
let sources = [ })
{type: 'mp3', url: this.$store.getters['instance/absoluteUrl'](this.track.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) { if (this.$store.state.auth.authenticated) {
// we need to send the token directly in url // we need to send the token directly in url
// so authentication can be checked by the backend // so authentication can be checked by the backend
@ -91,10 +111,40 @@ export default {
} }
}, },
methods: { 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 () { updateProgress: function () {
this.isUpdatingTime = true this.isUpdatingTime = true
if (this.sound && this.sound.state() === 'loaded') { if (this.sound && this.sound.state() === 'loaded') {
this.$store.dispatch('player/updateProgress', this.sound.seek()) this.$store.dispatch('player/updateProgress', this.sound.seek())
this.updateBuffer(this.sound._sounds[0]._node)
} }
}, },
observeProgress: function (enable) { observeProgress: function (enable) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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