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/ diff --git a/CHANGELOG b/CHANGELOG index 03665014f..07d1dedbd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,6 +6,20 @@ Changelog ---------------- - Front: Now reset player colors when track has no cover (#46) +- Front: play button now disabled for unplayable tracks + +Transcoding: + +Basic transcoding is now available to/from the following formats : ogg and mp3. + +*This is still an alpha feature at the moment, please report any bug.* + +This relies internally on FFMPEG and can put some load on your server. +It's definitely recommended you setup some caching for the transcoded files +at your webserver level. Check the the exemple nginx file at deploy/nginx.conf +for an implementation. + +On the frontend, usage of transcoding should be transparent in the player. 0.4 (2018-02-18) ---------------- 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/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/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/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') 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 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 diff --git a/deploy/nginx.conf b/deploy/nginx.conf index cf865a9ea..dfdbac2ae 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -39,6 +39,15 @@ server { root /srv/funkwhale/front/dist; + # global proxy conf + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; + proxy_redirect off; + location / { try_files $uri $uri/ @rewrites; } @@ -49,15 +58,9 @@ server { location /api/ { # this is needed if you have file import via upload enabled client_max_body_size 30M; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host:$server_port; - proxy_set_header X-Forwarded-Port $server_port; - proxy_redirect off; proxy_pass http://funkwhale-api/api/; } + location /media/ { alias /srv/funkwhale/data/media/; } @@ -70,6 +73,41 @@ server { alias /srv/funkwhale/data/media; } + # Transcoding logic and caching + location = /transcode-auth { + # needed so we can authenticate transcode requests, but still + # cache the result + internal; + set $query ''; + # ensure we actually pass the jwt to the underlytin auth url + if ($request_uri ~* "[^\?]+\?(.*)$") { + set $query $1; + } + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Port $server_port; + proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + } + + location /api/v1/trackfiles/transcode/ { + # this block deals with authenticating and caching transcoding + # requests. Caching is heavily recommended as transcoding + # is a CPU intensive process. + auth_request /transcode-auth; + if ($args ~ (.*)jwt=[^&]*(.*)) { + set $cleaned_args $1$2; + } + proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args"; + proxy_cache transcode; + proxy_cache_valid 200 7d; + proxy_ignore_headers "Set-Cookie"; + proxy_hide_header "Set-Cookie"; + add_header X-Cache-Status $upstream_cache_status; + proxy_pass http://funkwhale-api; + } + # end of transcoding logic + location /staticfiles/ { # django static files alias /srv/funkwhale/data/static/; diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 1b749c30a..29c04fc66 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -26,23 +26,59 @@ http { keepalive_timeout 65; #gzip on; + proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off; server { listen 6001; charset utf-8; client_max_body_size 20M; + + # global proxy pass config + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host localhost:8080; + proxy_set_header X-Forwarded-Port 8080; + proxy_redirect off; + location /_protected/media { internal; alias /protected/media; } - location / { - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + location = /transcode-auth { + # needed so we can authenticate transcode requests, but still + # cache the result + internal; + set $query ''; + # ensure we actually pass the jwt to the underlytin auth url + if ($request_uri ~* "[^\?]+\?(.*)$") { + set $query $1; + } proxy_set_header X-Forwarded-Host localhost:8080; proxy_set_header X-Forwarded-Port 8080; - proxy_redirect off; + proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + } + + location /api/v1/trackfiles/transcode/ { + # this block deals with authenticating and caching transcoding + # requests. Caching is heavily recommended as transcoding + # is a CPU intensive process. + auth_request /transcode-auth; + if ($args ~ (.*)jwt=[^&]*(.*)) { + set $cleaned_args $1$2; + } + proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args"; + proxy_cache transcode; + proxy_cache_valid 200 7d; + proxy_ignore_headers "Set-Cookie"; + proxy_hide_header "Set-Cookie"; + add_header X-Cache-Status $upstream_cache_status; + proxy_pass http://api:12081; + } + location / { proxy_pass http://api:12081/; } } 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/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 4767255ec..451cdcf01 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,6 +1,6 @@