From 4992029c8a7845313045ac346634ac2aae242868 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 18 Feb 2018 22:05:09 +0100 Subject: [PATCH 01/10] Ignore useless files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c1b8300f2..66ec5a41d 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,7 @@ api/music api/media api/staticfiles api/static - +api/.pytest_cache # Front front/node_modules/ From 937c55fdd531a858eba1b8f4d1a8dafec380ed46 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 18 Feb 2018 22:06:10 +0100 Subject: [PATCH 02/10] Install ffmpeg and magic --- api/Dockerfile | 2 +- api/docker/Dockerfile.test | 3 ++- api/requirements.apt | 3 ++- api/requirements/base.txt | 2 ++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 3281e6f56..5d4e85857 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.5 ENV PYTHONUNBUFFERED 1 # Requirements have to be pulled and installed here, otherwise caching won't work - +RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list COPY ./requirements.apt /requirements.apt RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1 diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test index 08b437cf2..069b89c2f 100644 --- a/api/docker/Dockerfile.test +++ b/api/docker/Dockerfile.test @@ -1,9 +1,10 @@ FROM python:3.5 ENV PYTHONUNBUFFERED 1 -ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONDONTWRITEBYTECODE 1 # Requirements have to be pulled and installed here, otherwise caching won't work +RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list COPY ./requirements.apt /requirements.apt COPY ./install_os_dependencies.sh /install_os_dependencies.sh RUN bash install_os_dependencies.sh install diff --git a/api/requirements.apt b/api/requirements.apt index e28360b56..462a5a705 100644 --- a/api/requirements.apt +++ b/api/requirements.apt @@ -5,6 +5,7 @@ libjpeg-dev zlib1g-dev libpq-dev postgresql-client -libav-tools +libmagic-dev +ffmpeg python3-dev curl diff --git a/api/requirements/base.txt b/api/requirements/base.txt index f38da9629..133fcc0cb 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -57,3 +57,5 @@ git+https://github.com/EliotBerriot/django-cachalot.git@django-2 django-dynamic-preferences>=1.5,<1.6 pyacoustid>=1.1.5,<1.2 raven>=6.5,<7 +python-magic==0.4.15 +ffmpeg-python==0.1.10 From ddea5f182570f9d558e195fcfe55ba533e547a12 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 18 Feb 2018 23:46:15 +0100 Subject: [PATCH 03/10] Now store track file mimetype in database --- .../migrations/0018_auto_20180218_1554.py | 28 +++++++++++++++ .../migrations/0019_populate_mimetypes.py | 34 +++++++++++++++++++ api/funkwhale_api/music/models.py | 6 ++++ api/funkwhale_api/music/serializers.py | 9 ++++- api/funkwhale_api/music/utils.py | 8 +++++ api/tests/music/test_models.py | 15 ++++++++ 6 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py create mode 100644 api/funkwhale_api/music/migrations/0019_populate_mimetypes.py diff --git a/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py new file mode 100644 index 000000000..c45298798 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py @@ -0,0 +1,28 @@ +# Generated by Django 2.0.2 on 2018-02-18 15:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0017_auto_20171227_1728'), + ] + + operations = [ + migrations.AddField( + model_name='trackfile', + name='mimetype', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AlterField( + model_name='importjob', + name='source', + field=models.CharField(max_length=500), + ), + migrations.AlterField( + model_name='importjob', + name='status', + field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30), + ), + ] diff --git a/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py new file mode 100644 index 000000000..127aa5e69 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import os + +from django.db import migrations, models +from funkwhale_api.music.utils import guess_mimetype + + +def populate_mimetype(apps, schema_editor): + TrackFile = apps.get_model("music", "TrackFile") + + for tf in TrackFile.objects.filter(audio_file__isnull=False, mimetype__isnull=True).only('audio_file'): + try: + tf.mimetype = guess_mimetype(tf.audio_file) + except Exception as e: + print('Error on track file {}: {}'.format(tf.pk, e)) + continue + print('Track file {}: {}'.format(tf.pk, tf.mimetype)) + tf.save(update_fields=['mimetype']) + + +def rewind(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0018_auto_20180218_1554'), + ] + + operations = [ + migrations.RunPython(populate_mimetype, rewind), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index f8373ab4d..3ebd07419 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -18,6 +18,7 @@ from versatileimagefield.fields import VersatileImageField from funkwhale_api import downloader from funkwhale_api import musicbrainz from . import importers +from . import utils class APIModelMixin(models.Model): @@ -364,6 +365,7 @@ class TrackFile(models.Model): source = models.URLField(null=True, blank=True) duration = models.IntegerField(null=True, blank=True) acoustid_track_id = models.UUIDField(null=True, blank=True) + mimetype = models.CharField(null=True, blank=True, max_length=200) def download_file(self): # import the track file, since there is not any @@ -393,6 +395,10 @@ class TrackFile(models.Model): self.track.full_name, os.path.splitext(self.audio_file.name)[-1]) + def save(self, **kwargs): + if not self.mimetype and self.audio_file: + self.mimetype = utils.guess_mimetype(self.audio_file) + return super().save(**kwargs) class ImportBatch(models.Model): IMPORT_BATCH_SOURCES = [ diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 506893a4d..41de30f10 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -28,7 +28,14 @@ class TrackFileSerializer(serializers.ModelSerializer): class Meta: model = models.TrackFile - fields = ('id', 'path', 'duration', 'source', 'filename', 'track') + fields = ( + 'id', + 'path', + 'duration', + 'source', + 'filename', + 'mimetype', + 'track') def get_path(self, o): url = o.path diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 32b1aeb47..0e4318e56 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -1,7 +1,9 @@ +import magic import re from django.db.models import Q + def normalize_query(query_string, findterms=re.compile(r'"([^"]+)"|(\S+)').findall, normspace=re.compile(r'\s{2,}').sub): @@ -15,6 +17,7 @@ def normalize_query(query_string, ''' return [normspace(' ', (t[0] or t[1]).strip()) for t in findterms(query_string)] + def get_query(query_string, search_fields): ''' Returns a query, that is a combination of Q objects. That combination aims to search keywords within a model by testing the given search fields. @@ -35,3 +38,8 @@ def get_query(query_string, search_fields): else: query = query & or_query return query + + +def guess_mimetype(f): + b = min(100000, f.size) + return magic.from_buffer(f.read(b), mime=True) diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 165415465..2eb1f2763 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -1,9 +1,12 @@ +import os import pytest from funkwhale_api.music import models from funkwhale_api.music import importers from funkwhale_api.music import tasks +DATA_DIR = os.path.dirname(os.path.abspath(__file__)) + def test_can_store_release_group_id_on_album(factories): album = factories['music.Album']() @@ -48,3 +51,15 @@ def test_import_job_is_bound_to_track_file(factories, mocker): tasks.import_job_run(import_job_id=job.pk) job.refresh_from_db() assert job.track_file.track == track + +@pytest.mark.parametrize('extention,mimetype', [ + ('ogg', 'audio/ogg'), + ('mp3', 'audio/mpeg'), +]) +def test_audio_track_mime_type(extention, mimetype, factories): + + name = '.'.join(['test', extention]) + path = os.path.join(DATA_DIR, name) + tf = factories['music.TrackFile'](audio_file__from_path=path) + + assert tf.mimetype == mimetype From 1cfdf31e00dfad0bc2cdd203edfdc1832047cb3f Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 18 Feb 2018 23:49:42 +0100 Subject: [PATCH 04/10] Can now stream transcoded version of audio tracks \o/ --- api/funkwhale_api/music/forms.py | 23 ++++++++++++++++++ api/funkwhale_api/music/views.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 api/funkwhale_api/music/forms.py diff --git a/api/funkwhale_api/music/forms.py b/api/funkwhale_api/music/forms.py new file mode 100644 index 000000000..04e4bfe05 --- /dev/null +++ b/api/funkwhale_api/music/forms.py @@ -0,0 +1,23 @@ +from django import forms + +from . import models + + +class TranscodeForm(forms.Form): + FORMAT_CHOICES = [ + ('ogg', 'ogg'), + ('mp3', 'mp3'), + ] + + to = forms.ChoiceField(choices=FORMAT_CHOICES) + BITRATE_CHOICES = [ + (64, '64'), + (128, '128'), + (256, '256'), + ] + bitrate = forms.ChoiceField( + choices=BITRATE_CHOICES, required=False) + + track_file = forms.ModelChoiceField( + queryset=models.TrackFile.objects.all() + ) diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 2395454c4..8e46cbd71 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -1,11 +1,16 @@ +import ffmpeg import os import json +import subprocess import unicodedata import urllib + from django.urls import reverse from django.db import models, transaction from django.db.models.functions import Length from django.conf import settings +from django.http import StreamingHttpResponse + from rest_framework import viewsets, views, mixins from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response @@ -19,6 +24,7 @@ from funkwhale_api.common.permissions import ( ConditionalAuthentication, HasModelPermission) from taggit.models import Tag +from . import forms from . import models from . import serializers from . import importers @@ -183,6 +189,40 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): f.audio_file.url) return response + @list_route(methods=['get']) + def viewable(self, request, *args, **kwargs): + return Response({}, status=200) + + @list_route(methods=['get']) + def transcode(self, request, *args, **kwargs): + form = forms.TranscodeForm(request.GET) + if not form.is_valid(): + return Response(form.errors, status=400) + + f = form.cleaned_data['track_file'] + output_kwargs = { + 'format': form.cleaned_data['to'] + } + args = (ffmpeg + .input(f.audio_file.path) + .output('pipe:', **output_kwargs) + .get_args() + ) + # we use a generator here so the view return immediatly and send + # file chunk to the browser, instead of blocking a few seconds + def _transcode(): + p = subprocess.Popen( + ['ffmpeg'] + args, + stdout=subprocess.PIPE) + for line in p.stdout: + yield line + + response = StreamingHttpResponse( + _transcode(), status=200, + content_type=form.cleaned_data['to']) + + return response + class TagViewSet(viewsets.ReadOnlyModelViewSet): queryset = Tag.objects.all().order_by('name') From d15fefe730484f4bb042547eeab20844a6f1f9db Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Sun, 18 Feb 2018 23:50:08 +0100 Subject: [PATCH 05/10] Leverage new transcode endpoint in player --- front/src/audio/formats.js | 10 +++++++++ front/src/components/audio/Track.vue | 33 ++++++++++++++++++---------- 2 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 front/src/audio/formats.js diff --git a/front/src/audio/formats.js b/front/src/audio/formats.js new file mode 100644 index 000000000..f6e2157a1 --- /dev/null +++ b/front/src/audio/formats.js @@ -0,0 +1,10 @@ +export default { + formats: [ + // 'audio/ogg', + 'audio/mpeg' + ], + formatsMap: { + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3' + } +} diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index a513c468f..d8dcaff9b 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -1,21 +1,20 @@