diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 4759d7aab2b51b76281782498b47e510bda168c2..a445e102cfc5ef07487379243cf089db0217d0c6 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -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 = {
diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py
index 5fc6f6804ca4c5e5289fb224c6e9adce503be978..70aecd632f6e77109dc3e8d51e06695acd19d6cf 100644
--- a/api/funkwhale_api/common/search.py
+++ b/api/funkwhale_api/common/search.py
@@ -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):
diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py
index 7e30c28a6a7283e0c57d049961e617fb60aec3c4..5f4e1cd42e34393ecf8545c62278a2097cc56deb 100644
--- a/api/funkwhale_api/favorites/views.py
+++ b/api/funkwhale_api/favorites/views.py
@@ -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))
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index ec3b4f3c080fabe174a340e3326b7365668f9c9e..56c30af36511e8cf124645dfcd00f835e3a04287 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -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))
diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py
index 8f9768857bbae298eb5587e526cc99a7c9f6e02d..fca544cc84a529d3cd3a468c1bec6858697427dc 100644
--- a/api/funkwhale_api/music/admin.py
+++ b/api/funkwhale_api/music/admin.py
@@ -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)
diff --git a/api/funkwhale_api/music/dynamic_preferences_registry.py b/api/funkwhale_api/music/dynamic_preferences_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..c46af502217ac39dce8d83c8923e023adf53c901
--- /dev/null
+++ b/api/funkwhale_api/music/dynamic_preferences_registry.py
@@ -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}
diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py
index 9571f978516535b357665a6672235be8abbd3b47..0ec3fdb227a61ab77c88d994fb3a66e495495554 100644
--- a/api/funkwhale_api/music/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -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")
diff --git a/api/funkwhale_api/music/migrations/0033_auto_20181023_1837.py b/api/funkwhale_api/music/migrations/0033_auto_20181023_1837.py
new file mode 100644
index 0000000000000000000000000000000000000000..003349b53caf8ae0766a10f283b8814f00d9e67c
--- /dev/null
+++ b/api/funkwhale_api/music/migrations/0033_auto_20181023_1837.py
@@ -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')},
+        ),
+    ]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index 87f7ba8191c62547c4ebeb4c7f41a3986ef23a77..a49b55236b0477ecec92d00d9e1466a7c954539b 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -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"),
diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py
index a23fc1daadf6d7bfbeb4b7d9d625475fcd6a1ad1..5d1dab00696fef6614c73371a4419c42cfbf2b6e 100644
--- a/api/funkwhale_api/music/serializers.py
+++ b/api/funkwhale_api/music/serializers.py
@@ -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):
diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py
index d96471b961c5fcfe0efa5bf1809f0e93f7106fb8..7008b12fc51ebc094b69876f4823ee1ab5c94261 100644
--- a/api/funkwhale_api/music/tasks.py
+++ b/api/funkwhale_api/music/tasks.py
@@ -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()
diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py
index 6da9ad9493647ad398a35e27faa8f7d04fb0eb7f..2c1210cf78c7cddeaba1968f0557d4f48c048302 100644
--- a/api/funkwhale_api/music/utils.py
+++ b/api/funkwhale_api/music/utils.py
@@ -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)
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index c8d1b94fc39a9b0c98f559b2d0b098c35515cd09..744fd43dd83a48ae236a81a9f7e278cc61758f61 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -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(
diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py
index e1895137d3630ec9970862ce3c85bbfc0472a0ee..1d33388015a80c618628b4923311cab49f15298c 100644
--- a/api/funkwhale_api/playlists/models.py
+++ b/api/funkwhale_api/playlists/models.py
@@ -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]
         )
diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py
index c1ca84e15be2708b6b328f582eadedd3aed08ce1..b64996640259c03a6394c1353bea641afb31dcf5 100644
--- a/api/funkwhale_api/playlists/serializers.py
+++ b/api/funkwhale_api/playlists/serializers.py
@@ -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
 
diff --git a/api/funkwhale_api/playlists/views.py b/api/funkwhale_api/playlists/views.py
index 4934b92a019529542702796d4de71d40144885f8..6ff49173c9413bd9c82f7575dd5d1533b1d63489 100644
--- a/api/funkwhale_api/playlists/views.py
+++ b/api/funkwhale_api/playlists/views.py
@@ -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(
diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py
index b8cf4b4bc444c3d8f4b03f08c1cedc0e948f139c..8aa9c9dbe893d7d1e0974cfc79def18e6e12f29c 100644
--- a/api/funkwhale_api/subsonic/views.py
+++ b/api/funkwhale_api/subsonic/views.py
@@ -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())
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index 246525b99070a02d5b6a456a318f694231b1b24a..06fbd4cc452659486cfdad4ddf748b55e5fdb6f2 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -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
diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py
index 6ac244c69fac164b49171b3d0f8dba20f13aa02d..0b99c93409a7ca1eb542fe2607d29a3b54c1293c 100644
--- a/api/tests/favorites/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -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
 
diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py
index d045a04c258ce61ee3c42c065e6768581a2e167d..874c35afa53a7e1a0a56cda2d3b5c5e91a07f3b7 100644
--- a/api/tests/music/test_models.py
+++ b/api/tests/music/test_models.py
@@ -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",
     [
diff --git a/api/tests/music/test_serializers.py b/api/tests/music/test_serializers.py
index 330371834d628713ea9cd2fc1cbfe772f19a65a7..3bd13a599150ee8a32af3e00932bd58454853df9 100644
--- a/api/tests/music/test_serializers.py
+++ b/api/tests/music/test_serializers.py
@@ -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
diff --git a/api/tests/music/test_tasks.py b/api/tests/music/test_tasks.py
index efa0e801f42dfe6c785a35ca4ee70d63dfd8be82..4e03d7906c674a34f9947d71f0684261ad4cad33 100644
--- a/api/tests/music/test_tasks.py
+++ b/api/tests/music/test_tasks.py
@@ -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()
diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py
index 389306820268157cc2e3d40a08bcc980edd4625d..615a592438990b5084679a8e8053974e3c6bc3f7 100644
--- a/api/tests/music/test_views.py
+++ b/api/tests/music/test_views.py
@@ -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):
diff --git a/api/tests/playlists/test_models.py b/api/tests/playlists/test_models.py
index 46c14d11c7f34ab7088840a60e5e57eb726b2a95..b90f525184b2f4863d8d9f7ab07314ac1e1a1712 100644
--- a/api/tests/playlists/test_models.py
+++ b/api/tests/playlists/test_models.py
@@ -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
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
index 1256347f3e379322020838e7504263703fbe267a..1c2b0f19eea43fce59dcf0ed166badd8e5603265 100644
--- a/api/tests/playlists/test_views.py
+++ b/api/tests/playlists/test_views.py
@@ -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
 
diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py
index 7cf5e8f120312004a1821738a60c80e400b9fc99..c0ae8407362b1fca31573b4b895ed0a58ca5097f 100644
--- a/api/tests/subsonic/test_views.py
+++ b/api/tests/subsonic/test_views.py
@@ -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")
diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py
index ce6aebbc3bcdd980207c22b70bc5615bbe780905..c6b8aea60e59b084edff401d25ff2af163f3f756 100644
--- a/api/tests/test_import_audio_file.py
+++ b/api/tests/test_import_audio_file.py
@@ -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
 
diff --git a/changes/changelog.d/272.feature b/changes/changelog.d/272.feature
new file mode 100644
index 0000000000000000000000000000000000000000..8a14193704f2ca1b835fc674967036a7a336e6d3
--- /dev/null
+++ b/changes/changelog.d/272.feature
@@ -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.
diff --git a/changes/changelog.d/586.enhancement b/changes/changelog.d/586.enhancement
new file mode 100644
index 0000000000000000000000000000000000000000..bee8c1c968f5ac7d5a9541235562ad675075bd25
--- /dev/null
+++ b/changes/changelog.d/586.enhancement
@@ -0,0 +1 @@
+The progress bar in the player now display loading state / buffer loading (#586)
diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue
index dcb1c507e121441aa9712aaca403f8dcc5759861..521a66f8715d0fce86e569ea1c2daa41e20630f6 100644
--- a/front/src/components/audio/PlayButton.vue
+++ b/front/src/components/audio/PlayButton.vue
@@ -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
         })
       })
     },
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index 5387d02b753e4b5626d637eaf78a57a4b5814cb9..80c54bbb2d9ad0f41174894e1a13611fee28250d 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -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>
diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue
index 5626dd4d89bf41b64563aa93faa799d323f4051f..c847d4de1a69f3f6cd1e6937e580d8ddf23332da 100644
--- a/front/src/components/audio/Track.vue
+++ b/front/src/components/audio/Track.vue
@@ -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) {
diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue
index b17cf117073190f767e82894de09d12106712d47..fd8b2daaf134473aff45cef895b104bc3a7f6ba7 100644
--- a/front/src/components/audio/track/Row.vue
+++ b/front/src/components/audio/track/Row.vue
@@ -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>
diff --git a/front/src/components/library/Track.vue b/front/src/components/library/Track.vue
index 483ff66739f582b3facd15237879c9321ae1c841..ddccda397352bc578999608489f153519f332fe8 100644
--- a/front/src/components/library/Track.vue
+++ b/front/src/components/library/Track.vue
@@ -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))
       }
diff --git a/front/src/store/index.js b/front/src/store/index.js
index 051e89b39e11291f7b303056399faa56e8abed15..0b8eb3321c27b00d8c31498ad602de7f9617c49a 100644
--- a/front/src/store/index.js
+++ b/front/src/store/index.js
@@ -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,
diff --git a/front/src/store/player.js b/front/src/store/player.js
index dc01f368b0c056499d39502eb24459197caa6366..fac17368d10377b959fb98520f242eb2f77f4aa2 100644
--- a/front/src/store/player.js
+++ b/front/src/store/player.js
@@ -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)
diff --git a/front/src/store/queue.js b/front/src/store/queue.js
index b6edb2242a3d09092baacf3b5b3af817bc9695dd..81403b11fa6d77d2e5332a77ed12ba3023d3f00e 100644
--- a/front/src/store/queue.js
+++ b/front/src/store/queue.js
@@ -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})
diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue
index 43bb54de337ffc4daa6cc30317f0846b214a3e01..0aa47a5c216498296fa7279a1a192c822a4c9cb4 100644
--- a/front/src/views/admin/Settings.vue
+++ b/front/src/views/admin/Settings.vue
@@ -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',
diff --git a/front/tests/unit/specs/store/queue.spec.js b/front/tests/unit/specs/store/queue.spec.js
index 373f4938e034864d65db318ba82a550fdcc012c4..282a4f02633758d878f7a89106844b6981fcccfd 100644
--- a/front/tests/unit/specs/store/queue.spec.js
+++ b/front/tests/unit/specs/store/queue.spec.js
@@ -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: [