Merge branch 'transcoding' into 'develop'

Transcoding

Closes #60

See merge request funkwhale/funkwhale!47
merge-requests/154/head
Eliot Berriot 2018-02-19 20:24:31 +00:00
commit 953d0ddc91
20 zmienionych plików z 325 dodań i 41 usunięć

2
.gitignore vendored
Wyświetl plik

@ -72,7 +72,7 @@ api/music
api/media
api/staticfiles
api/static
api/.pytest_cache
# Front
front/node_modules/

Wyświetl plik

@ -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)
----------------

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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()
)

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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),
]

Wyświetl plik

@ -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 = [

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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')

Wyświetl plik

@ -5,6 +5,7 @@ libjpeg-dev
zlib1g-dev
libpq-dev
postgresql-client
libav-tools
libmagic-dev
ffmpeg
python3-dev
curl

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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/;

Wyświetl plik

@ -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/;
}
}

Wyświetl plik

@ -0,0 +1,10 @@
export default {
formats: [
// 'audio/ogg',
'audio/mpeg'
],
formatsMap: {
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3'
}
}

Wyświetl plik

@ -1,6 +1,6 @@
<template>
<div :class="['ui', {'tiny': discrete}, 'buttons']">
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, 'button']">
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, {disabled: playableTracks.length === 0}, 'button']">
<i class="ui play icon"></i>
<template v-if="!discrete"><slot>Play</slot></template>
</button>
@ -36,20 +36,25 @@ export default {
jQuery(this.$el).find('.ui.dropdown').dropdown()
}
},
computed: {
playableTracks () {
let tracks
if (this.track) {
tracks = [this.track]
} else {
tracks = this.tracks
}
return tracks.filter(e => {
return e.files.length > 0
})
}
},
methods: {
add () {
if (this.track) {
this.$store.dispatch('queue/append', {track: this.track})
} else {
this.$store.dispatch('queue/appendMany', {tracks: this.tracks})
}
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks})
},
addNext (next) {
if (this.track) {
this.$store.dispatch('queue/append', {track: this.track, index: this.$store.state.queue.currentIndex + 1})
} else {
this.$store.dispatch('queue/appendMany', {tracks: this.tracks, index: this.$store.state.queue.currentIndex + 1})
}
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1})
if (next) {
this.$store.dispatch('queue/next')
}

Wyświetl plik

@ -1,21 +1,20 @@
<template>
<audio
ref="audio"
:src="url"
@error="errored"
@progress="updateLoad"
@loadeddata="loaded"
@durationchange="updateDuration"
@timeupdate="updateProgress"
@ended="ended"
preload>
<source v-for="src in srcs" :src="src.url" :type="src.type">
</audio>
</template>
<script>
import {mapState} from 'vuex'
import backend from '@/audio/backend'
import url from '@/utils/url'
import formats from '@/audio/formats'
// import logger from '@/logging'
@ -34,31 +33,43 @@ export default {
volume: state => state.player.volume,
looping: state => state.player.looping
}),
url: function () {
srcs: function () {
let file = this.track.files[0]
if (!file) {
this.$store.dispatch('player/trackErrored')
return null
return []
}
let path = backend.absoluteUrl(file.path)
let sources = [
{type: file.mimetype, url: file.path}
]
formats.formats.forEach(f => {
if (f !== file.mimetype) {
let format = formats.formatsMap[f]
let url = `/api/v1/trackfiles/transcode/?track_file=${file.id}&to=${format}`
sources.push({type: f, url: url})
}
})
if (this.$store.state.auth.authenticated) {
// we need to send the token directly in url
// so authentication can be checked by the backend
// because for audio files we cannot use the regular Authentication
// header
path = url.updateQueryString(path, 'jwt', this.$store.state.auth.token)
sources.forEach(e => {
e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token)
})
}
return path
return sources
}
},
methods: {
errored: function () {
this.$store.dispatch('player/trackErrored')
},
updateLoad: function () {
updateDuration: function (e) {
this.$store.commit('player/duration', this.$refs.audio.duration)
},
loaded: function () {
this.$refs.audio.volume = this.volume
if (this.isCurrent) {
this.$store.commit('player/duration', this.$refs.audio.duration)
if (this.startTime) {

Wyświetl plik

@ -50,7 +50,12 @@ export default {
},
getters: {
durationFormatted: state => {
return time.parse(Math.round(state.duration))
let duration = parseInt(state.duration)
if (duration % 1 !== 0) {
return time.parse(0)
}
duration = Math.round(state.duration)
return time.parse(duration)
},
currentTimeFormatted: state => {
return time.parse(Math.round(state.currentTime))