Merge branch 'release/0.14.1'

This commit is contained in:
Eliot Berriot 2018-06-06 22:03:41 +02:00
commit 30eaa78ef9
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
26 changed files with 425 additions and 222 deletions

View File

@ -10,6 +10,94 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
.. towncrier .. towncrier
0.14.1 (2018-06-06)
-------------------
Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html
Enhancements:
- Display server version in the footer (#270)
- fix_track_files will now update files with bad mimetype (and not only the one
with no mimetype) (#273)
- Huge performance boost (~x5 to x7) during CLI import that queries MusicBrainz
(#288)
- Removed alpha-state transcoding support (#271)
Bugfixes:
- Broken logging statement during import error (#274)
- Broken search bar on library home (#278)
- Do not crash when importing track with an artist that do not match the
release artist (#237)
- Do not crash when tag contains multiple uuids with a / separator (#267)
- Ensure we do not store bad mimetypes (such as application/x-empty) (#266)
- Fix broken "play all" button that played only 25 tracks (#281)
- Fixed broken track download modal (overflow and wrong URL) (#239)
- Removed hardcoded size limit in file upload widget (#275)
Documentation:
- Added warning about _protected/music location in nginx configuration (#247)
Removed alpha-state transcoding (#271)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
A few months ago, a basic transcoding feature was implemented. Due to the way
this feature was designed, it was slow, CPU intensive on the server side,
and very tightly coupled to the reverse-proxy configuration, preventing
it to work Apache2, for instance. It was also not compatible with Subsonic clients.
Based on that, we're currently removing support for transcoding
**in its current state**. The work on a better designed transcoding feature
can be tracked in https://code.eliotberriot.com/funkwhale/funkwhale/issues/272.
You don't have to do anything on your side, but you may want to remove
the now obsolete configuration from your reverse proxy file (nginx only)::
# Remove those blocks:
# transcode cache
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=7d;
# Transcoding logic and caching
location = /transcode-auth {
include /etc/nginx/funkwhale_proxy.conf;
# 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_pass http://funkwhale-api/api/v1/trackfiles/viewable/?$query;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
location /api/v1/trackfiles/transcode/ {
include /etc/nginx/funkwhale_proxy.conf;
# 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
0.14 (2018-06-02) 0.14 (2018-06-02)
----------------- -----------------

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = '0.14' __version__ = '0.14.1'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])

View File

@ -1,23 +0,0 @@
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.exclude(audio_file__isnull=True)
)

View File

@ -33,9 +33,9 @@ class Command(BaseCommand):
def fix_mimetypes(self, dry_run, **kwargs): def fix_mimetypes(self, dry_run, **kwargs):
self.stdout.write('Fixing missing mimetypes...') self.stdout.write('Fixing missing mimetypes...')
matching = models.TrackFile.objects.filter( matching = models.TrackFile.objects.filter(
source__startswith='file://', mimetype=None) source__startswith='file://').exclude(mimetype__startswith='audio/')
self.stdout.write( self.stdout.write(
'[mimetypes] {} entries found with no mimetype'.format( '[mimetypes] {} entries found with bad or no mimetype'.format(
matching.count())) matching.count()))
for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items(): for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items():
qs = matching.filter(source__endswith='.{}'.format(extension)) qs = matching.filter(source__endswith='.{}'.format(extension))

View File

@ -91,10 +91,23 @@ def convert_track_number(v):
pass pass
class FirstUUIDField(forms.UUIDField):
def to_python(self, value):
try:
# sometimes, Picard leaves to uuids in the field, separated
# by a slash
value = value.split('/')[0]
except (AttributeError, IndexError, TypeError):
pass
return super().to_python(value)
VALIDATION = { VALIDATION = {
'musicbrainz_artistid': forms.UUIDField(), 'musicbrainz_artistid': FirstUUIDField(),
'musicbrainz_albumid': forms.UUIDField(), 'musicbrainz_albumid': FirstUUIDField(),
'musicbrainz_recordingid': forms.UUIDField(), 'musicbrainz_recordingid': FirstUUIDField(),
} }
CONF = { CONF = {

View File

@ -334,6 +334,11 @@ class TrackQuerySet(models.QuerySet):
.prefetch_related('files')) .prefetch_related('files'))
def get_artist(release_list):
return Artist.get_or_create_from_api(
mbid=release_list[0]['artist-credits'][0]['artists']['id'])[0]
class Track(APIModelMixin): class Track(APIModelMixin):
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
artist = models.ForeignKey( artist = models.ForeignKey(
@ -363,8 +368,9 @@ class Track(APIModelMixin):
'musicbrainz_field_name': 'title' 'musicbrainz_field_name': 'title'
}, },
'artist': { 'artist': {
'musicbrainz_field_name': 'artist-credit', # we use the artist from the release to avoid #237
'converter': lambda v: Artist.get_or_create_from_api(mbid=v[0]['artist']['id'])[0], 'musicbrainz_field_name': 'release-list',
'converter': get_artist,
}, },
'album': { 'album': {
'musicbrainz_field_name': 'release-list', 'musicbrainz_field_name': 'release-list',
@ -431,7 +437,40 @@ class Track(APIModelMixin):
title__iexact=title, title__iexact=title,
defaults=kwargs) defaults=kwargs)
@classmethod
def get_or_create_from_release(cls, release_mbid, mbid):
release_mbid = str(release_mbid)
mbid = str(mbid)
try:
return cls.objects.get(mbid=mbid), False
except cls.DoesNotExist:
pass
album = Album.get_or_create_from_api(release_mbid)[0]
data = musicbrainz.client.api.releases.get(
str(album.mbid), includes=Album.api_includes)
tracks = [
t
for m in data['release']['medium-list']
for t in m['track-list']
]
track_data = None
for track in tracks:
if track['recording']['id'] == mbid:
track_data = track
break
if not track_data:
raise ValueError('No track found matching this ID')
return cls.objects.update_or_create(
mbid=mbid,
defaults={
'position': int(track['position']),
'title': track['recording']['title'],
'album': album,
'artist': album.artist,
}
)
class TrackFile(models.Model): class TrackFile(models.Model):
uuid = models.UUIDField( uuid = models.UUIDField(
unique=True, db_index=True, default=uuid.uuid4) unique=True, db_index=True, default=uuid.uuid4)

View File

@ -259,7 +259,9 @@ def get_cover_from_fs(dir_path):
'import_job') 'import_job')
def import_job_run(self, import_job, replace=False, use_acoustid=False): def import_job_run(self, import_job, replace=False, use_acoustid=False):
def mark_errored(exc): def mark_errored(exc):
logger.error('[Import Job %s] Error during import: %s', str(exc)) logger.error(
'[Import Job %s] Error during import: %s',
import_job.pk, str(exc))
import_job.status = 'errored' import_job.status = 'errored'
import_job.save(update_fields=['status']) import_job.save(update_fields=['status'])

View File

@ -43,9 +43,9 @@ def get_query(query_string, search_fields):
def guess_mimetype(f): def guess_mimetype(f):
b = min(100000, f.size) b = min(1000000, f.size)
t = magic.from_buffer(f.read(b), mime=True) t = magic.from_buffer(f.read(b), mime=True)
if t == 'application/octet-stream': if not t.startswith('audio/'):
# failure, we try guessing by extension # failure, we try guessing by extension
mt, _ = mimetypes.guess_type(f.path) mt, _ = mimetypes.guess_type(f.path)
if mt: if mt:

View File

@ -35,7 +35,6 @@ from funkwhale_api.musicbrainz import api
from funkwhale_api.requests.models import ImportRequest from funkwhale_api.requests.models import ImportRequest
from . import filters from . import filters
from . import forms
from . import importers from . import importers
from . import models from . import models
from . import permissions as music_permissions from . import permissions as music_permissions
@ -324,42 +323,6 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
except models.TrackFile.DoesNotExist: except models.TrackFile.DoesNotExist:
return Response(status=404) return Response(status=404)
@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']
if not f.audio_file:
return Response(status=400)
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): class TagViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Tag.objects.all().order_by('name') queryset = Tag.objects.all().order_by('name')

View File

@ -12,25 +12,43 @@ from funkwhale_api.music import models, metadata
@transaction.atomic @transaction.atomic
def import_track_data_from_path(path): def import_track_data_from_path(path):
data = metadata.Metadata(path) data = metadata.Metadata(path)
artist = models.Artist.objects.get_or_create( album = None
name__iexact=data.get('artist'), track_mbid = data.get('musicbrainz_recordingid', None)
defaults={ album_mbid = data.get('musicbrainz_albumid', None)
'name': data.get('artist'),
'mbid': data.get('musicbrainz_artistid', None), if album_mbid and track_mbid:
}, # to gain performance and avoid additional mb lookups,
)[0] # we import from the release data, which is already cached
return models.Track.get_or_create_from_release(
album_mbid, track_mbid)[0]
elif track_mbid:
return models.Track.get_or_create_from_api(track_mbid)[0]
elif album_mbid:
album = models.Album.get_or_create_from_api(album_mbid)[0]
artist = album.artist if album else None
artist_mbid = data.get('musicbrainz_artistid', None)
if not artist:
if artist_mbid:
artist = models.Artist.get_or_create_from_api(artist_mbid)[0]
else:
artist = models.Artist.objects.get_or_create(
name__iexact=data.get('artist'),
defaults={
'name': data.get('artist'),
},
)[0]
release_date = data.get('date', default=None) release_date = data.get('date', default=None)
album = models.Album.objects.get_or_create( if not album:
title__iexact=data.get('album'), album = models.Album.objects.get_or_create(
artist=artist, title__iexact=data.get('album'),
defaults={ artist=artist,
'title': data.get('album'), defaults={
'release_date': release_date, 'title': data.get('album'),
'mbid': data.get('musicbrainz_albumid', None), 'release_date': release_date,
}, },
)[0] )[0]
position = data.get('track_number', default=None) position = data.get('track_number', default=None)
track = models.Track.objects.get_or_create( track = models.Track.objects.get_or_create(
title__iexact=data.get('title'), title__iexact=data.get('title'),
@ -38,7 +56,6 @@ def import_track_data_from_path(path):
defaults={ defaults={
'title': data.get('title'), 'title': data.get('title'),
'position': position, 'position': position,
'mbid': data.get('musicbrainz_recordingid', None),
}, },
)[0] )[0]
return track return track

View File

@ -1,5 +1,9 @@
import os
from funkwhale_api.music.management.commands import fix_track_files from funkwhale_api.music.management.commands import fix_track_files
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
def test_fix_track_files_bitrate_length(factories, mocker): def test_fix_track_files_bitrate_length(factories, mocker):
tf1 = factories['music.TrackFile'](bitrate=1, duration=2) tf1 = factories['music.TrackFile'](bitrate=1, duration=2)
@ -43,3 +47,27 @@ def test_fix_track_files_size(factories, mocker):
# updated # updated
assert tf2.size == 2 assert tf2.size == 2
def test_fix_track_files_mimetype(factories, mocker):
name = 'test.mp3'
mp3_path = os.path.join(DATA_DIR, 'test.mp3')
ogg_path = os.path.join(DATA_DIR, 'test.ogg')
tf1 = factories['music.TrackFile'](
audio_file__from_path=mp3_path,
source='file://{}'.format(mp3_path),
mimetype='application/x-empty')
# this one already has a mimetype set, to it should not be updated
tf2 = factories['music.TrackFile'](
audio_file__from_path=ogg_path,
source='file://{}'.format(ogg_path),
mimetype='audio/something')
c = fix_track_files.Command()
c.fix_mimetypes(dry_run=False)
tf1.refresh_from_db()
tf2.refresh_from_db()
assert tf1.mimetype == 'audio/mpeg'
assert tf2.mimetype == 'audio/something'

View File

@ -95,3 +95,17 @@ def test_can_get_metadata_from_flac_file_not_crash_if_empty():
with pytest.raises(metadata.TagNotFound): with pytest.raises(metadata.TagNotFound):
data.get('test') data.get('test')
@pytest.mark.parametrize('field_name', [
'musicbrainz_artistid',
'musicbrainz_albumid',
'musicbrainz_recordingid',
])
def test_mbid_clean_keeps_only_first(field_name):
u1 = str(uuid.uuid4())
u2 = str(uuid.uuid4())
field = metadata.VALIDATION[field_name]
result = field.to_python('/'.join([u1, u2]))
assert str(result) == u1

View File

@ -43,6 +43,53 @@ def test_import_album_stores_release_group(factories):
assert album.artist == artist assert album.artist == artist
def test_import_track_from_release(factories, mocker):
album = factories['music.Album'](
mbid='430347cb-0879-3113-9fde-c75b658c298e')
album_data = {
'release': {
'id': album.mbid,
'title': 'Daydream Nation',
'status': 'Official',
'medium-count': 1,
'medium-list': [
{
'position': '1',
'format': 'CD',
'track-list': [
{
'id': '03baca8b-855a-3c05-8f3d-d3235287d84d',
'position': '4',
'number': '4',
'length': '417973',
'recording': {
'id': '2109e376-132b-40ad-b993-2bb6812e19d4',
'title': 'Teen Age Riot',
'length': '417973'},
'track_or_recording_length': '417973'
}
],
'track-count': 1
}
],
}
}
mocked_get = mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=album_data)
track_data = album_data['release']['medium-list'][0]['track-list'][0]
track = models.Track.get_or_create_from_release(
'430347cb-0879-3113-9fde-c75b658c298e',
track_data['recording']['id'],
)[0]
mocked_get.assert_called_once_with(
album.mbid, includes=models.Album.api_includes)
assert track.title == track_data['recording']['title']
assert track.mbid == track_data['recording']['id']
assert track.album == album
assert track.artist == album.artist
assert track.position == int(track_data['position'])
def test_import_job_is_bound_to_track_file(factories, mocker): def test_import_job_is_bound_to_track_file(factories, mocker):
track = factories['music.Track']() track = factories['music.Track']()
job = factories['music.ImportJob'](mbid=track.mbid) job = factories['music.ImportJob'](mbid=track.mbid)

View File

@ -15,9 +15,13 @@ def test_guess_mimetype_try_using_extension(factories, mocker):
assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg' assert utils.guess_mimetype(f.audio_file) == 'audio/mpeg'
def test_guess_mimetype_try_using_extension_if_fail(factories, mocker): @pytest.mark.parametrize('wrong', [
'application/octet-stream',
'application/x-empty',
])
def test_guess_mimetype_try_using_extension_if_fail(wrong, factories, mocker):
mocker.patch( mocker.patch(
'magic.from_buffer', return_value='application/octet-stream') 'magic.from_buffer', return_value=wrong)
f = factories['music.TrackFile'].build( f = factories['music.TrackFile'].build(
audio_file__filename='test.mp3') audio_file__filename='test.mp3')

View File

@ -15,16 +15,13 @@ DATA_DIR = os.path.join(
) )
def test_can_create_track_from_file_metadata(db, mocker): def test_can_create_track_from_file_metadata_no_mbid(db, mocker):
metadata = { metadata = {
'artist': ['Test artist'], 'artist': ['Test artist'],
'album': ['Test album'], 'album': ['Test album'],
'title': ['Test track'], 'title': ['Test track'],
'TRACKNUMBER': ['4'], 'TRACKNUMBER': ['4'],
'date': ['2012-08-15'], 'date': ['2012-08-15'],
'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'],
'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'],
'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'],
} }
m1 = mocker.patch('mutagen.File', return_value=metadata) m1 = mocker.patch('mutagen.File', return_value=metadata)
m2 = mocker.patch( m2 = mocker.patch(
@ -35,13 +32,64 @@ def test_can_create_track_from_file_metadata(db, mocker):
os.path.join(DATA_DIR, 'dummy_file.ogg')) os.path.join(DATA_DIR, 'dummy_file.ogg'))
assert track.title == metadata['title'][0] assert track.title == metadata['title'][0]
assert track.mbid == uuid.UUID(metadata['musicbrainz_trackid'][0]) assert track.mbid is None
assert track.position == 4 assert track.position == 4
assert track.album.title == metadata['album'][0] assert track.album.title == metadata['album'][0]
assert track.album.mbid == uuid.UUID(metadata['musicbrainz_albumid'][0]) assert track.album.mbid is None
assert track.album.release_date == datetime.date(2012, 8, 15) assert track.album.release_date == datetime.date(2012, 8, 15)
assert track.artist.name == metadata['artist'][0] assert track.artist.name == metadata['artist'][0]
assert track.artist.mbid == uuid.UUID(metadata['musicbrainz_artistid'][0]) assert track.artist.mbid is None
def test_can_create_track_from_file_metadata_mbid(factories, mocker):
album = factories['music.Album']()
mocker.patch(
'funkwhale_api.music.models.Album.get_or_create_from_api',
return_value=(album, True),
)
album_data = {
'release': {
'id': album.mbid,
'medium-list': [
{
'track-list': [
{
'id': '03baca8b-855a-3c05-8f3d-d3235287d84d',
'position': '4',
'number': '4',
'recording': {
'id': '2109e376-132b-40ad-b993-2bb6812e19d4',
'title': 'Teen Age Riot',
},
}
],
'track-count': 1
}
],
}
}
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=album_data)
track_data = album_data['release']['medium-list'][0]['track-list'][0]
metadata = {
'musicbrainz_albumid': [album.mbid],
'musicbrainz_trackid': [track_data['recording']['id']],
}
m1 = mocker.patch('mutagen.File', return_value=metadata)
m2 = mocker.patch(
'funkwhale_api.music.metadata.Metadata.get_file_type',
return_value='OggVorbis',
)
track = tasks.import_track_data_from_path(
os.path.join(DATA_DIR, 'dummy_file.ogg'))
assert track.title == track_data['recording']['title']
assert track.mbid == track_data['recording']['id']
assert track.position == 4
assert track.album == album
assert track.artist == album.artist
def test_management_command_requires_a_valid_username(factories, mocker): def test_management_command_requires_a_valid_username(factories, mocker):

View File

@ -1,5 +1,6 @@
Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html Upgrade instructions are available at
https://docs.funkwhale.audio/upgrading.html
{% for section, _ in sections.items() %} {% for section, _ in sections.items() %}
{% if sections[section] %} {% if sections[section] %}

View File

@ -1,8 +1,5 @@
# Ensure you update at least the server_name variables to match your own # Ensure you update at least the server_name variables to match your own
# transcode cache
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=7d;
# domain # domain
upstream funkwhale-api { upstream funkwhale-api {
# depending on your setup, you may want to udpate this # depending on your setup, you may want to udpate this
@ -98,41 +95,6 @@ server {
alias /srv/funkwhale/data/music; alias /srv/funkwhale/data/music;
} }
# Transcoding logic and caching
location = /transcode-auth {
include /etc/nginx/funkwhale_proxy.conf;
# 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_pass http://funkwhale-api/api/v1/trackfiles/viewable/?$query;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
location /api/v1/trackfiles/transcode/ {
include /etc/nginx/funkwhale_proxy.conf;
# 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/ { location /staticfiles/ {
# django static files # django static files
alias /srv/funkwhale/data/static/; alias /srv/funkwhale/data/static/;

View File

@ -26,7 +26,6 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
#gzip on; #gzip on;
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
@ -46,38 +45,6 @@ http {
internal; internal;
alias /music; alias /music;
} }
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;
}
include /etc/nginx/funkwhale_proxy.conf;
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;
}
include /etc/nginx/funkwhale_proxy.conf;
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 / { location / {
include /etc/nginx/funkwhale_proxy.conf; include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://api:12081/; proxy_pass http://api:12081/;

View File

@ -16,10 +16,8 @@ The project relies on the following components and services to work:
Hardware requirements Hardware requirements
--------------------- ---------------------
Funkwhale is not especially CPU hungry, unless you're relying heavily Funkwhale is not especially CPU hungry. On a dockerized instance with 2 CPUs
on the transcoding feature (which is basic and unoptimized at the moment). and a few active users, the memory footprint is around ~500Mb::
On a dockerized instance with 2 CPUs and a few active users, the memory footprint is around ~500Mb::
CONTAINER MEM USAGE CONTAINER MEM USAGE
funkwhale_api_1 202.1 MiB funkwhale_api_1 202.1 MiB
@ -116,6 +114,13 @@ Then, download our sample virtualhost file and proxy conf:
Ensure static assets and proxy pass match your configuration, and check the configuration is valid with ``nginx -t``. Ensure static assets and proxy pass match your configuration, and check the configuration is valid with ``nginx -t``.
If everything is fine, you can restart your nginx server with ``service nginx restart``. If everything is fine, you can restart your nginx server with ``service nginx restart``.
.. warning::
If you plan to use to in-place import, ensure the alias value
in the ``_protected/music`` location matches your MUSIC_DIRECTORY_SERVE_PATH
env var.
Apache2 Apache2
^^^^^^^ ^^^^^^^
@ -125,10 +130,8 @@ Apache2
are not working yet: are not working yet:
- Websocket (used for real-time updates on Instance timeline) - Websocket (used for real-time updates on Instance timeline)
- Transcoding of audio files
Those features are not necessary to use your Funkwhale instance, and Those features are not necessary to use your Funkwhale instance.
transcoding in particular is still in alpha-state anyway.
Ensure you have a recent version of apache2 installed on your server. Ensure you have a recent version of apache2 installed on your server.
You'll also need the following dependencies:: You'll also need the following dependencies::

View File

@ -12,10 +12,13 @@
<router-link class="item" to="/about"> <router-link class="item" to="/about">
<i18next path="About this instance" /> <i18next path="About this instance" />
</router-link> </router-link>
<i18next tag="a" href="https://funkwhale.audio" class="item" target="_blank" path="Official website" /> <a href="https://funkwhale.audio" class="item" target="_blank">{{ $t('Official website') }}</a>
<i18next tag="a" href="https://docs.funkwhale.audio" class="item" target="_blank" path="Documentation" /> <a href="https://docs.funkwhale.audio" class="item" target="_blank">{{ $t('Documentation') }}</a>
<i18next tag="a" href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank" path="Source code" /> <a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">
<i18next tag="a" href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank" path="Issue tracker" /> <template v-if="version">{{ $t('Source code ({% version %})', {version: version}) }}</template>
<template v-else>{{ $t('Source code') }}</template>
</a>
<a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">{{ $t('Issue tracker') }}</a>
</div> </div>
</div> </div>
<div class="ten wide column"> <div class="ten wide column">
@ -39,6 +42,9 @@
</template> </template>
<script> <script>
import axios from 'axios'
import _ from 'lodash'
import Sidebar from '@/components/Sidebar' import Sidebar from '@/components/Sidebar'
import Raven from '@/components/Raven' import Raven from '@/components/Raven'
@ -51,6 +57,11 @@ export default {
Raven, Raven,
PlaylistModal PlaylistModal
}, },
data () {
return {
nodeinfo: null
}
},
created () { created () {
this.$store.dispatch('instance/fetchSettings') this.$store.dispatch('instance/fetchSettings')
let self = this let self = this
@ -58,6 +69,23 @@ export default {
// used to redraw ago dates every minute // used to redraw ago dates every minute
self.$store.commit('ui/computeLastDate') self.$store.commit('ui/computeLastDate')
}, 1000 * 60) }, 1000 * 60)
this.fetchNodeInfo()
},
methods: {
fetchNodeInfo () {
let self = this
axios.get('instance/nodeinfo/2.0/').then(response => {
self.nodeinfo = response.data
})
}
},
computed: {
version () {
if (!this.nodeinfo) {
return null
}
return _.get(this.nodeinfo, 'software.version')
}
} }
} }
</script> </script>

View File

@ -26,7 +26,11 @@ export default {
return url return url
} }
if (url.startsWith('/')) { if (url.startsWith('/')) {
return config.BACKEND_URL + url.substr(1) let rootUrl = (
window.location.protocol + '//' + window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
)
return rootUrl + url
} else { } else {
return config.BACKEND_URL + url return config.BACKEND_URL + url
} }

View File

@ -67,8 +67,31 @@ export default {
} }
}, },
methods: { methods: {
getTracksPage (page, params, resolve, tracks) {
if (page > 10) {
// it's 10 * 100 tracks already, let's stop here
resolve(tracks)
}
// when fetching artists/or album tracks, sometimes, we may have to fetch
// multiple pages
let self = this
params['page_size'] = 100
params['page'] = page
tracks = tracks || []
axios.get('tracks/', {params: params}).then((response) => {
response.data.results.forEach(t => {
tracks.push(t)
})
if (response.data.next) {
self.getTracksPage(page + 1, params, resolve, tracks)
} else {
resolve(tracks)
}
})
},
getPlayableTracks () { getPlayableTracks () {
let self = this let self = this
this.isLoading = true
let getTracks = new Promise((resolve, reject) => { let getTracks = new Promise((resolve, reject) => {
if (self.track) { if (self.track) {
resolve([self.track]) resolve([self.track])
@ -82,44 +105,30 @@ export default {
})) }))
}) })
} else if (self.artist) { } else if (self.artist) {
let params = { let params = {'artist': self.artist, 'ordering': 'album__release_date,position'}
params: {'artist': self.artist, 'ordering': 'album__release_date,position'} self.getTracksPage(1, params, resolve)
}
axios.get('tracks', params).then((response) => {
resolve(response.data.results)
})
} else if (self.album) { } else if (self.album) {
let params = { let params = {'album': self.album, 'ordering': 'position'}
params: {'album': self.album, 'ordering': 'position'} self.getTracksPage(1, params, resolve)
}
axios.get('tracks', params).then((response) => {
resolve(response.data.results)
})
} }
}) })
return getTracks.then((tracks) => { return getTracks.then((tracks) => {
setTimeout(e => {
self.isLoading = false
}, 250)
return tracks.filter(e => { return tracks.filter(e => {
return e.files.length > 0 return e.files.length > 0
}) })
}) })
}, },
triggerLoad () {
let self = this
this.isLoading = true
setTimeout(() => {
self.isLoading = false
}, 500)
},
add () { add () {
let self = this let self = this
this.triggerLoad()
this.getPlayableTracks().then((tracks) => { this.getPlayableTracks().then((tracks) => {
self.$store.dispatch('queue/appendMany', {tracks: tracks}) self.$store.dispatch('queue/appendMany', {tracks: tracks})
}) })
}, },
addNext (next) { addNext (next) {
let self = this let self = this
this.triggerLoad()
let wasEmpty = this.$store.state.queue.tracks.length === 0 let wasEmpty = this.$store.state.queue.tracks.length === 0
this.getPlayableTracks().then((tracks) => { this.getPlayableTracks().then((tracks) => {
self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1}) self.$store.dispatch('queue/appendMany', {tracks: tracks, index: self.$store.state.queue.currentIndex + 1})

View File

@ -29,9 +29,9 @@
</template> </template>
<script> <script>
import _ from 'lodash'
import axios from 'axios' import axios from 'axios'
import logger from '@/logging' import logger from '@/logging'
import backend from '@/audio/backend'
import AlbumCard from '@/components/audio/album/Card' import AlbumCard from '@/components/audio/album/Card'
import ArtistCard from '@/components/audio/artist/Card' import ArtistCard from '@/components/audio/artist/Card'
@ -50,7 +50,6 @@ export default {
albums: [], albums: [],
artists: [] artists: []
}, },
backend: backend,
isLoading: false isLoading: false
} }
}, },
@ -61,7 +60,7 @@ export default {
this.search() this.search()
}, },
methods: { methods: {
search () { search: _.debounce(function () {
if (this.query.length < 1) { if (this.query.length < 1) {
return return
} }
@ -77,15 +76,11 @@ export default {
self.results = self.castResults(response.data) self.results = self.castResults(response.data)
self.isLoading = false self.isLoading = false
}) })
}, }, 500),
castResults (results) { castResults (results) {
return { return {
albums: results.albums.map((album) => { albums: results.albums,
return backend.Album.clean(album) artists: results.artists
}),
artists: results.artists.map((artist) => {
return backend.Artist.clean(artist)
})
} }
} }
}, },

View File

@ -18,7 +18,6 @@
<script> <script>
import {mapState} from 'vuex' import {mapState} from 'vuex'
import url from '@/utils/url' import url from '@/utils/url'
import formats from '@/audio/formats'
import _ from 'lodash' import _ from 'lodash'
// import logger from '@/logging' // import logger from '@/logging'
@ -52,13 +51,6 @@ export default {
let sources = [ let sources = [
{type: file.mimetype, url: file.path} {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) { if (this.$store.state.auth.authenticated) {
// we need to send the token directly in url // we need to send the token directly in url
// so authentication can be checked by the backend // so authentication can be checked by the backend

View File

@ -84,4 +84,7 @@ export default {
tr:not(:hover) .favorite-icon:not(.favorited) { tr:not(:hover) .favorite-icon:not(.favorited) {
display: none; display: none;
} }
pre {
overflow-x: scroll;
}
</style> </style>

View File

@ -9,7 +9,6 @@
:class="['ui', 'icon', 'left', 'floated', 'button']" :class="['ui', 'icon', 'left', 'floated', 'button']"
:post-action="uploadUrl" :post-action="uploadUrl"
:multiple="true" :multiple="true"
:size="1024 * 1024 * 30"
:data="uploadData" :data="uploadData"
:drop="true" :drop="true"
extensions="ogg,mp3,flac" extensions="ogg,mp3,flac"