Merge branch 'develop' into i18n-components

This commit is contained in:
Bat 2018-04-22 09:45:22 +01:00
commit 28c067b43c
50 changed files with 580 additions and 134 deletions

View File

@ -9,3 +9,4 @@ FUNKWHALE_HOSTNAME=localhost
FUNKWHALE_PROTOCOL=http
PYTHONDONTWRITEBYTECODE=true
WEBPACK_DEVSERVER_PORT=8080
MUSIC_DIRECTORY_PATH=/music

View File

@ -70,7 +70,9 @@ build_front:
- yarn install
- yarn run i18n-extract
- yarn run i18n-compile
- yarn run build
# this is to ensure we don't have any errors in the output,
# cf https://code.eliotberriot.com/funkwhale/funkwhale/issues/169
- yarn run build | tee /dev/stderr | (! grep -i 'ERROR in')
cache:
key: "$CI_PROJECT_ID__front_dependencies"
paths:

View File

@ -3,6 +3,52 @@ Changelog
.. towncrier
0.10 (Unreleased)
-----------------
In-place import
^^^^^^^^^^^^^^^
This release includes in-place imports for the CLI import. This means you can
load gigabytes of music into funkwhale without worrying about about Funkwhale
copying those music files in its internal storage and eating your disk space.
This new feature is documented <here> and require additional configuration
to ensure funkwhale and your webserver can serve those files properly.
**Non-docker users:**
Assuming your music is stored in ``/srv/funkwhale/data/music``, add the following
block to your nginx configuration::
location /_protected/music {
internal;
alias /srv/funkwhale/data/music;
}
And the following to your .env file::
MUSIC_DIRECTORY_PATH=/srv/funkwhale/data/music
**Docker users:**
Assuming your music is stored in ``/srv/funkwhale/data/music``, add the following
block to your nginx configuration::
location /_protected/music {
internal;
alias /srv/funkwhale/data/music;
}
Assuming you have the following volume directive in your ``docker-compose.yml``
(it's the default): ``/srv/funkwhale/data/music:/music:ro``, then add
the following to your .env file::
MUSIC_DIRECTORY_PATH=/music
MUSIC_DIRECTORY_SERVE_PATH=/srv/funkwhale/data/music
0.9.1 (2018-04-17)
------------------

View File

@ -441,3 +441,9 @@ EXTERNAL_REQUESTS_VERIFY_SSL = env.bool(
'EXTERNAL_REQUESTS_VERIFY_SSL',
default=True
)
MUSIC_DIRECTORY_PATH = env('MUSIC_DIRECTORY_PATH', default=None)
# on Docker setup, the music directory may not match the host path,
# and we need to know it for it to serve stuff properly
MUSIC_DIRECTORY_SERVE_PATH = env(
'MUSIC_DIRECTORY_SERVE_PATH', default=MUSIC_DIRECTORY_PATH)

View File

@ -43,6 +43,7 @@ class TrackFactory(factory.django.DjangoModelFactory):
artist = factory.SelfAttribute('album.artist')
position = 1
tags = ManyToManyFromList('tags')
class Meta:
model = 'music.Track'
@ -57,6 +58,9 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
model = 'music.TrackFile'
class Params:
in_place = factory.Trait(
audio_file=None,
)
federation = factory.Trait(
audio_file=None,
library_track=factory.SubFactory(LibraryTrackFactory),
@ -105,6 +109,10 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
status='finished',
track_file=factory.SubFactory(TrackFileFactory),
)
in_place = factory.Trait(
status='finished',
audio_file=None,
)
@registry.register(name='music.FileImportJob')

View File

@ -1,5 +1,6 @@
import mutagen
from django import forms
import arrow
import mutagen
NODEFAULT = object()
@ -50,6 +51,13 @@ def convert_track_number(v):
except (ValueError, AttributeError, IndexError):
pass
VALIDATION = {
'musicbrainz_artistid': forms.UUIDField(),
'musicbrainz_albumid': forms.UUIDField(),
'musicbrainz_recordingid': forms.UUIDField(),
}
CONF = {
'OggVorbis': {
'getter': lambda f, k: f[k][0],
@ -146,4 +154,7 @@ class Metadata(object):
converter = field_conf.get('to_application')
if converter:
v = converter(v)
field = VALIDATION.get(key)
if field:
v = field.to_python(v)
return v

View File

@ -0,0 +1,18 @@
# Generated by Django 2.0.3 on 2018-04-19 20:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0024_populate_uuid'),
]
operations = [
migrations.AlterField(
model_name='trackfile',
name='source',
field=models.URLField(blank=True, max_length=500, null=True),
),
]

View File

@ -412,7 +412,7 @@ class TrackFile(models.Model):
track = models.ForeignKey(
Track, related_name='files', on_delete=models.CASCADE)
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
source = models.URLField(null=True, blank=True)
source = models.URLField(null=True, blank=True, max_length=500)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
duration = models.IntegerField(null=True, blank=True)
@ -463,6 +463,26 @@ class TrackFile(models.Model):
self.mimetype = utils.guess_mimetype(self.audio_file)
return super().save(**kwargs)
@property
def serve_from_source_path(self):
if not self.source or not self.source.startswith('file://'):
raise ValueError('Cannot serve this file from source')
serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
prefix = settings.MUSIC_DIRECTORY_PATH
if not serve_path or not prefix:
raise ValueError(
'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
'MUSIC_DIRECTORY_PATH to serve in-place imported files'
)
file_path = self.source.replace('file://', '', 1)
parts = os.path.split(file_path.replace(prefix, '', 1))
if parts[0] == '/':
parts = parts[1:]
return os.path.join(
serve_path,
*parts
)
IMPORT_STATUS_CHOICES = (
('pending', 'Pending'),
@ -507,6 +527,8 @@ class ImportBatch(models.Model):
def update_status(self):
old_status = self.status
self.status = utils.compute_status(self.jobs.all())
if self.status == old_status:
return
self.save(update_fields=['status'])
if self.status != old_status and self.status == 'finished':
from . import tasks

View File

@ -71,7 +71,7 @@ def import_track_from_remote(library_track):
library_track.title, artist=artist, album=album)
def _do_import(import_job, replace, use_acoustid=True):
def _do_import(import_job, replace=False, use_acoustid=True):
from_file = bool(import_job.audio_file)
mbid = import_job.mbid
acoustid_track_id = None
@ -93,6 +93,9 @@ def _do_import(import_job, replace, use_acoustid=True):
track = import_track_data_from_path(import_job.audio_file.path)
elif import_job.library_track:
track = import_track_from_remote(import_job.library_track)
elif import_job.source.startswith('file://'):
track = import_track_data_from_path(
import_job.source.replace('file://', '', 1))
else:
raise ValueError(
'Not enough data to process import, '
@ -123,7 +126,7 @@ def _do_import(import_job, replace, use_acoustid=True):
else:
# no downloading, we hotlink
pass
else:
elif import_job.audio_file:
track_file.download_file()
track_file.save()
import_job.status = 'finished'
@ -133,7 +136,7 @@ def _do_import(import_job, replace, use_acoustid=True):
import_job.audio_file.delete()
import_job.save()
return track.pk
return track_file
@celery.app.task(name='ImportJob.run', bind=True)
@ -147,7 +150,8 @@ def import_job_run(self, import_job, replace=False, use_acoustid=True):
import_job.save(update_fields=['status'])
try:
return _do_import(import_job, replace, use_acoustid=use_acoustid)
tf = _do_import(import_job, replace, use_acoustid=use_acoustid)
return tf.pk if tf else None
except Exception as exc:
if not settings.DEBUG:
try:

View File

@ -53,10 +53,11 @@ def guess_mimetype(f):
def compute_status(jobs):
errored = any([job.status == 'errored' for job in jobs])
statuses = jobs.order_by().values_list('status', flat=True).distinct()
errored = any([status == 'errored' for status in statuses])
if errored:
return 'errored'
pending = any([job.status == 'pending' for job in jobs])
pending = any([status == 'pending' for status in statuses])
if pending:
return 'pending'
return 'finished'

View File

@ -23,13 +23,14 @@ from rest_framework import permissions
from musicbrainzngs import ResponseError
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.federation import actors
from funkwhale_api.requests.models import ImportRequest
from funkwhale_api.musicbrainz import api
from funkwhale_api.common.permissions import (
ConditionalAuthentication, HasModelPermission)
from taggit.models import Tag
from funkwhale_api.federation import actors
from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation.models import LibraryTrack
from funkwhale_api.musicbrainz import api
from funkwhale_api.requests.models import ImportRequest
from . import filters
from . import forms
@ -195,12 +196,13 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
@detail_route(methods=['get'])
def serve(self, request, *args, **kwargs):
queryset = models.TrackFile.objects.select_related(
'library_track',
'track__album__artist',
'track__artist',
)
try:
f = models.TrackFile.objects.select_related(
'library_track',
'track__album__artist',
'track__artist',
).get(pk=kwargs['pk'])
f = queryset.get(pk=kwargs['pk'])
except models.TrackFile.DoesNotExist:
return Response(status=404)
@ -213,14 +215,30 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
if library_track and not audio_file:
if not library_track.audio_file:
# we need to populate from cache
library_track.download_audio()
with transaction.atomic():
# why the transaction/select_for_update?
# this is because browsers may send multiple requests
# in a short time range, for partial content,
# thus resulting in multiple downloads from the remote
qs = LibraryTrack.objects.select_for_update()
library_track = qs.get(pk=library_track.pk)
library_track.download_audio()
audio_file = library_track.audio_file
file_path = '{}{}'.format(
settings.PROTECT_FILES_PATH,
audio_file.url)
mt = library_track.audio_mimetype
elif audio_file:
file_path = '{}{}'.format(
settings.PROTECT_FILES_PATH,
audio_file.url)
elif f.source and f.source.startswith('file://'):
file_path = '{}{}'.format(
settings.PROTECT_FILES_PATH + '/music',
f.serve_from_source_path)
response = Response()
filename = f.filename
response['X-Accel-Redirect'] = "{}{}".format(
settings.PROTECT_FILES_PATH,
audio_file.url)
response['X-Accel-Redirect'] = file_path
filename = "filename*=UTF-8''{}".format(
urllib.parse.quote(filename))
response["Content-Disposition"] = "attachment; {}".format(filename)

View File

@ -1,11 +1,11 @@
import glob
import os
from django.conf import settings
from django.core.files import File
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from funkwhale_api.common import utils
from funkwhale_api.music import models
from funkwhale_api.music import tasks
from funkwhale_api.users.models import User
@ -39,7 +39,20 @@ class Command(BaseCommand):
action='store_true',
dest='exit_on_failure',
default=False,
help='use this flag to disable error catching',
help='Use this flag to disable error catching',
)
parser.add_argument(
'--in-place', '-i',
action='store_true',
dest='in_place',
default=False,
help=(
'Import files without duplicating them into the media directory.'
'For in-place import to work, the music files must be readable'
'by the web-server and funkwhale api and celeryworker processes.'
'You may want to use this if you have a big music library to '
'import and not much disk space available.'
)
)
parser.add_argument(
'--no-acoustid',
@ -54,21 +67,29 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
# self.stdout.write(self.style.SUCCESS('Successfully closed poll "%s"' % poll_id))
# Recursive is supported only on Python 3.5+, so we pass the option
# only if it's True to avoid breaking on older versions of Python
glob_kwargs = {}
if options['recursive']:
glob_kwargs['recursive'] = True
try:
matching = glob.glob(options['path'], **glob_kwargs)
matching = sorted(glob.glob(options['path'], **glob_kwargs))
except TypeError:
raise Exception('You need Python 3.5 to use the --recursive flag')
self.stdout.write('This will import {} files matching this pattern: {}'.format(
len(matching), options['path']))
if options['in_place']:
self.stdout.write(
'Checking imported paths against settings.MUSIC_DIRECTORY_PATH')
p = settings.MUSIC_DIRECTORY_PATH
if not p:
raise CommandError(
'Importing in-place requires setting the '
'MUSIC_DIRECTORY_PATH variable')
for m in matching:
if not m.startswith(p):
raise CommandError(
'Importing in-place only works if importing'
'from {} (MUSIC_DIRECTORY_PATH), as this directory'
'needs to be accessible by the webserver.'
'Culprit: {}'.format(p, m))
if not matching:
raise CommandError('No file matching pattern, aborting')
@ -86,6 +107,24 @@ class Command(BaseCommand):
except AssertionError:
raise CommandError(
'No superuser available, please provide a --username')
filtered = self.filter_matching(matching, options)
self.stdout.write('Import summary:')
self.stdout.write('- {} files found matching this pattern: {}'.format(
len(matching), options['path']))
self.stdout.write('- {} files already found in database'.format(
len(filtered['skipped'])))
self.stdout.write('- {} new files'.format(
len(filtered['new'])))
self.stdout.write('Selected options: {}'.format(', '.join([
'no acoustid' if options['no_acoustid'] else 'use acoustid',
'in place' if options['in_place'] else 'copy music files',
])))
if len(filtered['new']) == 0:
self.stdout.write('Nothing new to import, exiting')
return
if options['interactive']:
message = (
'Are you sure you want to do this?\n\n'
@ -94,27 +133,52 @@ class Command(BaseCommand):
if input(''.join(message)) != 'yes':
raise CommandError("Import cancelled.")
batch = self.do_import(matching, user=user, options=options)
batch, errors = self.do_import(
filtered['new'], user=user, options=options)
message = 'Successfully imported {} tracks'
if options['async']:
message = 'Successfully launched import for {} tracks'
self.stdout.write(message.format(len(matching)))
self.stdout.write(message.format(len(filtered['new'])))
if len(errors) > 0:
self.stderr.write(
'{} tracks could not be imported:'.format(len(errors)))
for path, error in errors:
self.stderr.write('- {}: {}'.format(path, error))
self.stdout.write(
"For details, please refer to import batch #{}".format(batch.pk))
@transaction.atomic
def do_import(self, matching, user, options):
message = 'Importing {}...'
def filter_matching(self, matching, options):
sources = ['file://{}'.format(p) for p in matching]
# we skip reimport for path that are already found
# as a TrackFile.source
existing = models.TrackFile.objects.filter(source__in=sources)
existing = existing.values_list('source', flat=True)
existing = set([p.replace('file://', '', 1) for p in existing])
skipped = set(matching) & existing
result = {
'initial': matching,
'skipped': list(sorted(skipped)),
'new': list(sorted(set(matching) - skipped)),
}
return result
def do_import(self, paths, user, options):
message = '{i}/{total} Importing {path}...'
if options['async']:
message = 'Launching import for {}...'
message = '{i}/{total} Launching import for {path}...'
# we create an import batch binded to the user
batch = user.imports.create(source='shell')
async = options['async']
import_handler = tasks.import_job_run.delay if async else tasks.import_job_run
for path in matching:
batch = user.imports.create(source='shell')
total = len(paths)
errors = []
for i, path in list(enumerate(paths)):
try:
self.stdout.write(message.format(path))
self.stdout.write(
message.format(path=path, i=i+1, total=len(paths)))
self.import_file(path, batch, import_handler, options)
except Exception as e:
if options['exit_on_failure']:
@ -122,18 +186,19 @@ class Command(BaseCommand):
m = 'Error while importing {}: {} {}'.format(
path, e.__class__.__name__, e)
self.stderr.write(m)
return batch
errors.append((path, '{} {}'.format(e.__class__.__name__, e)))
return batch, errors
def import_file(self, path, batch, import_handler, options):
job = batch.jobs.create(
source='file://' + path,
)
name = os.path.basename(path)
with open(path, 'rb') as f:
job.audio_file.save(name, File(f))
if not options['in_place']:
name = os.path.basename(path)
with open(path, 'rb') as f:
job.audio_file.save(name, File(f))
job.save()
utils.on_commit(
import_handler,
job.save()
import_handler(
import_job_id=job.pk,
use_acoustid=not options['no_acoustid'])

View File

@ -2,12 +2,14 @@ import acoustid
import os
import datetime
from django.core.files import File
from django.db import transaction
from funkwhale_api.taskapp import celery
from funkwhale_api.providers.acoustid import get_acoustid_client
from funkwhale_api.music import models, metadata
@transaction.atomic
def import_track_data_from_path(path):
data = metadata.Metadata(path)
artist = models.Artist.objects.get_or_create(
@ -45,6 +47,7 @@ def import_track_data_from_path(path):
def import_metadata_with_musicbrainz(path):
pass
@celery.app.task(name='audiofile.from_path')
def from_path(path):
acoustid_track_id = None

View File

@ -231,3 +231,15 @@ def test_import_batch_notifies_followers(
on_behalf_of=library_actor,
to=[f1.actor.url]
)
def test__do_import_in_place_mbid(factories, tmpfile):
path = '/test.ogg'
job = factories['music.ImportJob'](
in_place=True, source='file:///test.ogg')
track = factories['music.Track'](mbid=job.mbid)
tf = tasks._do_import(job, use_acoustid=False)
assert bool(tf.audio_file) is False
assert tf.source == 'file:///test.ogg'

View File

@ -1,6 +1,7 @@
import datetime
import os
import pytest
import uuid
from funkwhale_api.music import metadata
@ -13,9 +14,9 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
('album', 'Peer Gynt Suite no. 1, op. 46'),
('date', datetime.date(2012, 8, 15)),
('track_number', 1),
('musicbrainz_albumid', 'a766da8b-8336-47aa-a3ee-371cc41ccc75'),
('musicbrainz_recordingid', 'bd21ac48-46d8-4e78-925f-d9cc2a294656'),
('musicbrainz_artistid', '013c8e5b-d72a-4cd3-8dee-6c64d6125823'),
('musicbrainz_albumid', uuid.UUID('a766da8b-8336-47aa-a3ee-371cc41ccc75')),
('musicbrainz_recordingid', uuid.UUID('bd21ac48-46d8-4e78-925f-d9cc2a294656')),
('musicbrainz_artistid', uuid.UUID('013c8e5b-d72a-4cd3-8dee-6c64d6125823')),
])
def test_can_get_metadata_from_ogg_file(field, value):
path = os.path.join(DATA_DIR, 'test.ogg')
@ -30,9 +31,9 @@ def test_can_get_metadata_from_ogg_file(field, value):
('album', 'You Can\'t Stop Da Funk'),
('date', datetime.date(2006, 2, 7)),
('track_number', 1),
('musicbrainz_albumid', 'ce40cdb1-a562-4fd8-a269-9269f98d4124'),
('musicbrainz_recordingid', 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb'),
('musicbrainz_artistid', '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13'),
('musicbrainz_albumid', uuid.UUID('ce40cdb1-a562-4fd8-a269-9269f98d4124')),
('musicbrainz_recordingid', uuid.UUID('f269d497-1cc0-4ae4-a0c4-157ec7d73fcb')),
('musicbrainz_artistid', uuid.UUID('9c6bddde-6228-4d9f-ad0d-03f6fcb19e13')),
])
def test_can_get_metadata_from_id3_mp3_file(field, value):
path = os.path.join(DATA_DIR, 'test.mp3')

View File

@ -93,6 +93,25 @@ def test_can_proxy_remote_track(
assert library_track.audio_file.read() == b'test'
def test_can_serve_in_place_imported_file(
factories, settings, api_client, r_mock):
settings.PROTECT_AUDIO_FILES = False
settings.MUSIC_DIRECTORY_SERVE_PATH = '/host/music'
settings.MUSIC_DIRECTORY_PATH = '/music'
settings.MUSIC_DIRECTORY_PATH = '/music'
track_file = factories['music.TrackFile'](
in_place=True,
source='file:///music/test.ogg')
response = api_client.get(track_file.path)
assert response.status_code == 200
assert response['X-Accel-Redirect'] == '{}{}'.format(
settings.PROTECT_FILES_PATH,
'/music/host/music/test.ogg'
)
def test_can_create_import_from_federation_tracks(
factories, superuser_api_client, mocker):
lts = factories['federation.LibraryTrack'].create_batch(size=5)

View File

@ -2,6 +2,8 @@ import pytest
import acoustid
import datetime
import os
import uuid
from django.core.management import call_command
from django.core.management.base import CommandError
@ -15,7 +17,8 @@ DATA_DIR = os.path.join(
def test_can_create_track_from_file_metadata(db, mocker):
mocker.patch('acoustid.match', side_effect=acoustid.WebServiceError('test'))
mocker.patch(
'acoustid.match', side_effect=acoustid.WebServiceError('test'))
metadata = {
'artist': ['Test artist'],
'album': ['Test album'],
@ -35,33 +38,49 @@ def test_can_create_track_from_file_metadata(db, mocker):
os.path.join(DATA_DIR, 'dummy_file.ogg'))
assert track.title == metadata['title'][0]
assert track.mbid == metadata['musicbrainz_trackid'][0]
assert track.mbid == uuid.UUID(metadata['musicbrainz_trackid'][0])
assert track.position == 4
assert track.album.title == metadata['album'][0]
assert track.album.mbid == metadata['musicbrainz_albumid'][0]
assert track.album.mbid == uuid.UUID(metadata['musicbrainz_albumid'][0])
assert track.album.release_date == datetime.date(2012, 8, 15)
assert track.artist.name == metadata['artist'][0]
assert track.artist.mbid == metadata['musicbrainz_artistid'][0]
assert track.artist.mbid == uuid.UUID(metadata['musicbrainz_artistid'][0])
def test_management_command_requires_a_valid_username(factories, mocker):
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
user = factories['users.User'](username='me')
mocker.patch('funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import') # NOQA
mocker.patch(
'funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import', # noqa
return_value=(mocker.MagicMock(), []))
with pytest.raises(CommandError):
call_command('import_files', path, username='not_me', interactive=False)
call_command('import_files', path, username='me', interactive=False)
def test_in_place_import_only_from_music_dir(factories, settings):
user = factories['users.User'](username='me')
settings.MUSIC_DIRECTORY_PATH = '/nope'
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
with pytest.raises(CommandError):
call_command(
'import_files',
path,
in_place=True,
username='me',
interactive=False
)
def test_import_files_creates_a_batch_and_job(factories, mocker):
m = mocker.patch('funkwhale_api.common.utils.on_commit')
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
call_command(
'import_files',
path,
username='me',
async=True,
async=False,
interactive=False)
batch = user.imports.latest('id')
@ -76,45 +95,79 @@ def test_import_files_creates_a_batch_and_job(factories, mocker):
assert job.source == 'file://' + path
m.assert_called_once_with(
music_tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=True)
def test_import_files_skip_acoustid(factories, mocker):
m = mocker.patch('funkwhale_api.common.utils.on_commit')
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
call_command(
'import_files',
path,
username='me',
async=True,
async=False,
no_acoustid=True,
interactive=False)
batch = user.imports.latest('id')
job = batch.jobs.first()
m.assert_called_once_with(
music_tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=False)
def test_import_files_skip_if_path_already_imported(factories, mocker):
user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
existing = factories['music.TrackFile'](
source='file://{}'.format(path))
call_command(
'import_files',
path,
username='me',
async=False,
no_acoustid=True,
interactive=False)
assert user.imports.count() == 0
def test_import_files_works_with_utf8_file_name(factories, mocker):
m = mocker.patch('funkwhale_api.common.utils.on_commit')
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
call_command(
'import_files',
path,
username='me',
async=True,
async=False,
no_acoustid=True,
interactive=False)
batch = user.imports.latest('id')
job = batch.jobs.first()
m.assert_called_once_with(
music_tasks.import_job_run.delay,
import_job_id=job.pk,
use_acoustid=False)
def test_import_files_in_place(factories, mocker, settings):
settings.MUSIC_DIRECTORY_PATH = DATA_DIR
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
call_command(
'import_files',
path,
username='me',
async=False,
in_place=True,
no_acoustid=True,
interactive=False)
batch = user.imports.latest('id')
job = batch.jobs.first()
assert bool(job.audio_file) is False
m.assert_called_once_with(
import_job_id=job.pk,
use_acoustid=False)

View File

@ -0,0 +1 @@
Reset all sensitive front-end data on logout (#124)

View File

@ -0,0 +1 @@
Increased max_length on TrackFile.source, this will help when importing files with a really long path (#142)

View File

@ -0,0 +1 @@
Better file import performance and error handling (#144)

View File

@ -0,0 +1 @@
Can now import files in-place from the CLI importe (#155)

View File

@ -0,0 +1 @@
Fixed broken playlist modal after login (#155)

View File

@ -0,0 +1 @@
Avoid downloading audio files multiple times from remote libraries (#163)

View File

@ -0,0 +1 @@
Better documentation for hardware requirements and memory usage (#165)

View File

@ -1,17 +1,22 @@
# If you have any doubts about what a setting does,
# check https://docs.funkwhale.audio/configuration.html#configuration-reference
# If you're tweaking this file from the template, ensure you edit at least the
# following variables:
# - DJANGO_SECRET_KEY
# - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL
# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL
# - CACHE_URL
# - STATIC_ROOT
# - MEDIA_ROOT
#
# You **don't** need to update those variables on pure docker setups.
#
# Additional options you may want to check:
# - MUSIC_DIRECTORY_PATH and MUSIC_DIRECTORY_SERVE_PATH if you plan to use
# in-place import
# Docker only
# -----------
@ -19,7 +24,9 @@
# (it will be interpolated in docker-compose file)
# You can comment or ignore this if you're not using docker
FUNKWHALE_VERSION=latest
MUSIC_DIRECTORY_PATH=/music
# End of Docker-only configuration
# General configuration
# ---------------------
@ -34,6 +41,7 @@ FUNKWHALE_API_PORT=5000
# your instance
FUNKWHALE_URL=https://yourdomain.funwhale
# API/Django configuration
# Database configuration
@ -94,3 +102,9 @@ FEDERATION_ENABLED=True
# means anyone can subscribe to your library and import your file,
# use with caution.
FEDERATION_MUSIC_NEEDS_APPROVAL=True
# In-place import settings
# You can safely leave those settings uncommented if you don't plan to use
# in place imports.
# MUSIC_DIRECTORY_PATH=
# MUSIC_DIRECTORY_SERVE_PATH=

View File

@ -84,6 +84,14 @@ server {
alias /srv/funkwhale/data/media;
}
location /_protected/music {
# this is an internal location that is used to serve
# audio files once correct permission / authentication
# has been checked on API side
internal;
alias /srv/funkwhale/data/music;
}
# Transcoding logic and caching
location = /transcode-auth {
include /etc/nginx/funkwhale_proxy.conf;

View File

@ -65,7 +65,7 @@ services:
- "CACHE_URL=redis://redis:6379/0"
volumes:
- ./api:/app
- ./data/music:/music
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
networks:
- internal
api:
@ -78,7 +78,7 @@ services:
command: python /app/manage.py runserver 0.0.0.0:12081
volumes:
- ./api:/app
- ./data/music:/music
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
environment:
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
@ -107,6 +107,7 @@ services:
volumes:
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
- ./api/funkwhale_api/media:/protected/media
ports:

View File

@ -42,6 +42,10 @@ http {
internal;
alias /protected/media;
}
location /_protected/music {
internal;
alias /music;
}
location = /transcode-auth {
# needed so we can authenticate transcode requests, but still
# cache the result

View File

@ -33,3 +33,44 @@ The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (pre
If you plan to use acoustid and external imports
(e.g. with the youtube backends), you should edit the corresponding
settings in this interface.
Configuration reference
-----------------------
.. _setting-MUSIC_DIRECTORY_PATH:
``MUSIC_DIRECTORY_PATH``
^^^^^^^^^^^^^^^^^^^^^^^^
Default: ``None``
The path on your server where Funwkhale can import files using :ref:`in-place import
<in-place-import>`. It must be readable by the webserver and funkwhale
api and worker processes.
On docker installations, we recommend you use the default of ``/music``
for this value. For non-docker installation, you can use any absolute path.
``/srv/funkwhale/data/music`` is a safe choice if you don't know what to use.
.. note:: This path should not include any trailing slash
.. _setting-MUSIC_DIRECTORY_SERVE_PATH:
``MUSIC_DIRECTORY_SERVE_PATH``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Default: :ref:`setting-MUSIC_DIRECTORY_PATH`
When using Docker, the value of :ref:`MUSIC_DIRECTORY_PATH` in your containers
may differ from the real path on your host. Assuming you have the following directive
in your :file:`docker-compose.yml` file::
volumes:
- /srv/funkwhale/data/music:/music:ro
Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be
``/srv/funkwhale/data``. This must be readable by the webserver.
On non-docker setup, you don't need to configure this setting.
.. note:: This path should not include any trailing slash

View File

@ -22,8 +22,15 @@ to the ``/music`` directory on the container:
docker-compose run --rm api python manage.py import_files "/music/**/*.ogg" --recursive --noinput
For the best results, we recommand tagging your music collection through
`Picard <http://picard.musicbrainz.org/>`_ in order to have the best quality metadata.
The import command supports several options, and you can check the help to
get details::
docker-compose run --rm api python manage.py import_files --help
.. note::
For the best results, we recommand tagging your music collection through
`Picard <http://picard.musicbrainz.org/>`_ in order to have the best quality metadata.
.. note::
@ -39,18 +46,39 @@ For the best results, we recommand tagging your music collection through
At the moment, only OGG/Vorbis and MP3 files with ID3 tags are supported
.. note::
The --recursive flag will work only on Python 3.5+, which is the default
version When using Docker or Debian 9. If you use an older version of Python,
remove the --recursive flag and use more explicit import patterns instead::
.. _in-place-import:
# this will only import ogg files at the second level
"/srv/funkwhale/data/music/*/*.ogg"
# this will only import ogg files in the fiven directory
"/srv/funkwhale/data/music/System-of-a-down/*.ogg"
In-place import
^^^^^^^^^^^^^^^
By default, the CLI-importer will copy imported files to Funkwhale's internal
storage. This means importing a 1Gb library will result in the same amount
of space being used by Funkwhale.
While this behaviour has some benefits (easier backups and configuration),
it's not always the best choice, especially if you have a huge library
to import and don't want to double your disk usage.
The CLI importer supports an additional ``--in-place`` option that triggers the
following behaviour during import:
1. Imported files are not store in funkwhale anymore
2. Instead, Funkwhale will store the file path and use it to serve the music
Because those files are not managed by Funkwhale, we offer additional
configuration options to ensure the webserver can serve them properly:
- :ref:`setting-MUSIC_DIRECTORY_PATH`
- :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH`
.. warning::
While in-place import is faster and less disk-space-hungry, it's also
more fragile: if, for some reason, you move or rename the source files,
Funkwhale will not be able to serve those files anymore.
Thus, be especially careful when you manipulate the source files.
Getting demo tracks
^^^^^^^^^^^^^^^^^^^

View File

@ -35,7 +35,7 @@
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> {{ $t('Logout') }}</router-link>
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> {{ $t('Login') }}</router-link>
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> {{ $t('Favorites') }}</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i> {{ $t('Favorites') }}</router-link>
<a
@click="$store.commit('playlists/chooseTrack', null)"
v-if="$store.state.auth.authenticated"

View File

@ -48,7 +48,7 @@
<div
v-if="track"
class="ui green icon basic small right floated button"
:title="{{ $t('Add to this playlist') }}"
:title="$t('Add to this playlist')"
@click="addToPlaylist(playlist.id)">
<i class="plus icon"></i> {{ $t('Add track') }}
</div>

View File

@ -19,6 +19,14 @@ export default {
}
},
mutations: {
reset (state) {
state.authenticated = false
state.profile = null
state.username = ''
state.token = ''
state.tokenData = {}
state.availablePermissions = {}
},
profile: (state, value) => {
state.profile = value
},
@ -53,8 +61,6 @@ export default {
return axios.post('token/', credentials).then(response => {
logger.default.info('Successfully logged in as', credentials.username)
commit('token', response.data.token)
commit('username', credentials.username)
commit('authenticated', true)
dispatch('fetchProfile')
// Redirect to a specified route
router.push(next)
@ -64,19 +70,25 @@ export default {
})
},
logout ({commit}) {
commit('authenticated', false)
let modules = [
'auth',
'favorites',
'player',
'playlists',
'queue',
'radios'
]
modules.forEach(m => {
commit(`${m}/reset`, null, {root: true})
})
logger.default.info('Log out, goodbye!')
router.push({name: 'index'})
},
check ({commit, dispatch, state}) {
logger.default.info('Checking authentication...')
var jwt = state.token
var username = state.username
if (jwt) {
commit('authenticated', true)
commit('username', username)
commit('token', jwt)
logger.default.info('Logged back in as ' + username)
dispatch('fetchProfile')
dispatch('refreshToken')
} else {
@ -88,6 +100,7 @@ export default {
return axios.get('users/users/me/').then((response) => {
logger.default.info('Successfully fetched user profile')
let data = response.data
commit('authenticated', true)
commit('profile', data)
commit('username', data.username)
dispatch('favorites/fetch', null, {root: true})

View File

@ -20,6 +20,10 @@ export default {
}
}
state.count = state.tracks.length
},
reset (state) {
state.tracks = []
state.count = 0
}
},
getters: {

View File

@ -15,6 +15,10 @@ export default {
looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue
},
mutations: {
reset (state) {
state.errorCount = 0
state.playing = false
},
volume (state, value) {
value = parseFloat(value)
value = Math.min(value, 1)

View File

@ -17,6 +17,11 @@ export default {
},
showModal (state, value) {
state.showModal = value
},
reset (state) {
state.playlists = []
state.modalTrack = null
state.showModal = false
}
},
actions: {

View File

@ -10,6 +10,12 @@ export default {
previousQueue: null
},
mutations: {
reset (state) {
state.tracks = []
state.currentIndex = -1
state.ended = true
state.previousQueue = null
},
currentIndex (state, value) {
state.currentIndex = value
},

View File

@ -26,6 +26,10 @@ export default {
}
},
mutations: {
reset (state) {
state.running = false
state.current = false
},
current: (state, value) => {
state.current = value
},

View File

@ -3,16 +3,16 @@
<div class="ui secondary pointing menu">
<router-link
class="ui item"
:to="{name: 'federation.libraries.list'}">Libraries</router-link>
:to="{name: 'federation.libraries.list'}">{{ $t('Libraries') }}</router-link>
<router-link
class="ui item"
:to="{name: 'federation.tracks.list'}">Tracks</router-link>
:to="{name: 'federation.tracks.list'}">{{ $t('Tracks') }}</router-link>
<div class="ui secondary right menu">
<router-link
class="ui item"
:to="{name: 'federation.followers.list'}">
Followers
<div class="ui teal label" title="Pending requests">{{ requestsCount }}</div>
{{ $t('Followers') }}
<div class="ui teal label" :title="$t('Pending requests')">{{ requestsCount }}</div>
</router-link>
</div>
</div>

View File

@ -19,18 +19,18 @@
<tbody>
<tr>
<td >
Follow status
{{ $t('Follow status') }}
<span :data-tooltip="$t('This indicate if the remote library granted you access')"><i class="question circle icon"></i></span>
</td>
<td>
<template v-if="object.follow.approved === null">
<i class="loading icon"></i> Pending approval
<i class="loading icon"></i> {{ $t('Pending approval') }}
</template>
<template v-else-if="object.follow.approved === true">
<i class="check icon"></i> Following
<i class="check icon"></i> {{ $t('Following') }}
</template>
<template v-else-if="object.follow.approved === false">
<i class="x icon"></i> Not following
<i class="x icon"></i> {{ $t('Not following') }}
</template>
</td>
<td>
@ -38,7 +38,7 @@
</tr>
<tr>
<td>
Federation
{{ $t('Federation') }}
<span :data-tooltip="$t('Use this flag to enable/disable federation with this library')"><i class="question circle icon"></i></span>
</td>
<td>
@ -54,7 +54,7 @@
</tr>
<tr>
<td>
Auto importing
{{ $t('Auto importing') }}
<span :data-tooltip="$t('When enabled, auto importing will automatically import new tracks published in this library')"><i class="question circle icon"></i></span>
</td>
<td>
@ -82,14 +82,14 @@
</tr>
-->
<tr>
<td>Library size</td>
<td>{{ $t('Library size') }}</td>
<td>
{{ object.tracks_count }} tracks
{{ $t('{%count%} tracks', { count: object.tracks_count }) }}
</td>
<td></td>
</tr>
<tr>
<td>Last fetched</td>
<td>{{ $t('Last fetched') }}</td>
<td>
<human-date v-if="object.fetched_date" :date="object.fetched_date"></human-date>
<template v-else>Never</template>
@ -97,10 +97,10 @@
@click="scan"
v-if="!scanTrigerred"
:class="['ui', 'basic', {loading: isScanLoading}, 'button']">
<i class="sync icon"></i> Trigger scan
<i class="sync icon"></i> {{ $t('Trigger scan') }}
</button>
<button v-else class="ui success button">
<i class="check icon"></i> Scan triggered!
<i class="check icon"></i> {{ $t('Scan triggered!') }}
</button>
</td>
@ -110,10 +110,10 @@
</table>
</div>
<div class="ui hidden divider"></div>
<button @click="fetchData" class="ui basic button">Refresh</button>
<button @click="fetchData" class="ui basic button">{{ $t('Refresh') }}</button>
</div>
<div class="ui vertical stripe segment">
<h2>Tracks available in this library</h2>
<h2>{{ $t('Tracks available in this library') }}</h2>
<library-track-table v-if="!isLoading" :filters="{library: id}"></library-track-table>
</div>
</template>

View File

@ -1,10 +1,9 @@
<template>
<div v-title="'Followers'">
<div class="ui vertical stripe segment">
<h2 class="ui header">Browsing followers</h2>
<h2 class="ui header">{{ $t('Browsing followers') }}</h2>
<p>
Be careful when accepting follow requests, as it means the follower
will have access to your entire library.
{{ $t('Be careful when accepting follow requests, as it means the follower will have access to your entire library.') }}
</p>
<div class="ui hidden divider"></div>
<library-follow-table></library-follow-table>

View File

@ -1,22 +1,22 @@
<template>
<div v-title="'Libraries'">
<div class="ui vertical stripe segment">
<h2 class="ui header">Browsing libraries</h2>
<h2 class="ui header">{{ $t('Browsing libraries') }}</h2>
<router-link
class="ui basic green button"
:to="{name: 'federation.libraries.scan'}">
<i class="plus icon"></i>
Add a new library
{{ $t('Add a new library') }}
</router-link>
<div class="ui hidden divider"></div>
<div :class="['ui', {'loading': isLoading}, 'form']">
<div class="fields">
<div class="field">
<label>Search</label>
<label>{{ $t('Search') }}</label>
<input type="text" v-model="query" placeholder="Enter an library domain name..."/>
</div>
<div class="field">
<label>Ordering</label>
<label>{{ $t('Ordering') }}</label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ option[1] }}
@ -24,14 +24,14 @@
</select>
</div>
<div class="field">
<label>Ordering direction</label>
<label>{{ $t('Ordering direction') }}</label>
<select class="ui dropdown" v-model="orderingDirection">
<option value="">Ascending</option>
<option value="-">Descending</option>
<option value="">{{ $t('Ascending') }}</option>
<option value="-">{{ $t('Descending') }}</option>
</select>
</div>
<div class="field">
<label>Results per page</label>
<label>{{ $t('Results per page') }}</label>
<select class="ui dropdown" v-model="paginateBy">
<option :value="parseInt(12)">12</option>
<option :value="parseInt(25)">25</option>

View File

@ -1,7 +1,7 @@
<template>
<div v-title="'Federated tracks'">
<div class="ui vertical stripe segment">
<h2 class="ui header">Browsing federated tracks</h2>
<h2 class="ui header">{{ $t('Browsing federated tracks') }}</h2>
<div class="ui hidden divider"></div>
<library-track-table :show-library="true"></library-track-table>
</div>

View File

@ -2,10 +2,10 @@
<div class="main pusher" v-title="'Instance Timeline'">
<div class="ui vertical center aligned stripe segment">
<div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
<div class="ui text loader">Loading timeline...</div>
<div class="ui text loader">{{ $t('Loading timeline...') }}</div>
</div>
<div v-else class="ui text container">
<h1 class="ui header">Recent activity on this instance</h1>
<h1 class="ui header">{{ $t('Recent activity on this instance') }}</h1>
<div class="ui feed">
<component
class="event"

View File

@ -0,0 +1,10 @@
import Username from '@/components/common/Username.vue'
import { render } from '../../utils'
describe('Username', () => {
it('displays username', () => {
const vm = render(Username, {username: 'Hello'})
expect(vm.$el.textContent).to.equal('Hello')
})
})

View File

@ -89,7 +89,12 @@ describe('store/auth', () => {
action: store.actions.logout,
params: {state: {}},
expectedMutations: [
{ type: 'authenticated', payload: false }
{ type: 'auth/reset', payload: null, options: {root: true} },
{ type: 'favorites/reset', payload: null, options: {root: true} },
{ type: 'player/reset', payload: null, options: {root: true} },
{ type: 'playlists/reset', payload: null, options: {root: true} },
{ type: 'queue/reset', payload: null, options: {root: true} },
{ type: 'radios/reset', payload: null, options: {root: true} }
]
}, done)
})
@ -107,8 +112,6 @@ describe('store/auth', () => {
action: store.actions.check,
params: {state: {token: 'test', username: 'user'}},
expectedMutations: [
{ type: 'authenticated', payload: true },
{ type: 'username', payload: 'user' },
{ type: 'token', payload: 'test' }
],
expectedActions: [
@ -131,9 +134,7 @@ describe('store/auth', () => {
action: store.actions.login,
payload: {credentials: credentials},
expectedMutations: [
{ type: 'token', payload: 'test' },
{ type: 'username', payload: 'bob' },
{ type: 'authenticated', payload: true }
{ type: 'token', payload: 'test' }
],
expectedActions: [
{ type: 'fetchProfile' }
@ -175,13 +176,14 @@ describe('store/auth', () => {
testAction({
action: store.actions.fetchProfile,
expectedMutations: [
{ type: 'authenticated', payload: true },
{ type: 'profile', payload: profile },
{ type: 'username', payload: profile.username },
{ type: 'permission', payload: {key: 'admin', status: true} }
],
expectedActions: [
{ type: 'favorites/fetch', payload: null, options: {root: true} },
{ type: 'playlists/fetchOwn', payload: null, options: {root: true} },
{ type: 'playlists/fetchOwn', payload: null, options: {root: true} }
]
}, done)
})

View File

@ -132,7 +132,7 @@ describe('store/player', () => {
testAction({
action: store.actions.trackEnded,
payload: {test: 'track'},
params: {rootState: {queue: {currentIndex:0, tracks: [1, 2]}}},
params: {rootState: {queue: {currentIndex: 0, tracks: [1, 2]}}},
expectedActions: [
{ type: 'trackListened', payload: {test: 'track'} },
{ type: 'queue/next', payload: null, options: {root: true} }
@ -143,7 +143,7 @@ describe('store/player', () => {
testAction({
action: store.actions.trackEnded,
payload: {test: 'track'},
params: {rootState: {queue: {currentIndex:1, tracks: [1, 2]}}},
params: {rootState: {queue: {currentIndex: 1, tracks: [1, 2]}}},
expectedActions: [
{ type: 'trackListened', payload: {test: 'track'} },
{ type: 'radios/populateQueue', payload: null, options: {root: true} },

View File

@ -326,7 +326,7 @@ describe('store/queue', () => {
action: store.actions.shuffle,
params: {state: {currentIndex: 1, tracks: tracks}},
expectedMutations: [
{ type: 'player/currentTime', payload: 0 , options: {root: true}},
{ type: 'player/currentTime', payload: 0, options: {root: true} },
{ type: 'tracks', payload: [] }
],
expectedActions: [

View File

@ -97,6 +97,5 @@ describe('store/radios', () => {
expectedActions: []
}, done)
})
})
})

View File

@ -1,4 +1,11 @@
// helper for testing action with expected mutations
import Vue from 'vue'
export const render = (Component, propsData) => {
const Constructor = Vue.extend(Component)
return new Constructor({ propsData: propsData }).$mount()
}
export const testAction = ({action, payload, params, expectedMutations, expectedActions}, done) => {
let mutationsCount = 0
let actionsCount = 0