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 FUNKWHALE_PROTOCOL=http
PYTHONDONTWRITEBYTECODE=true PYTHONDONTWRITEBYTECODE=true
WEBPACK_DEVSERVER_PORT=8080 WEBPACK_DEVSERVER_PORT=8080
MUSIC_DIRECTORY_PATH=/music

View File

@ -70,7 +70,9 @@ build_front:
- yarn install - yarn install
- yarn run i18n-extract - yarn run i18n-extract
- yarn run i18n-compile - 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: cache:
key: "$CI_PROJECT_ID__front_dependencies" key: "$CI_PROJECT_ID__front_dependencies"
paths: paths:

View File

@ -3,6 +3,52 @@ Changelog
.. towncrier .. 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) 0.9.1 (2018-04-17)
------------------ ------------------

View File

@ -441,3 +441,9 @@ EXTERNAL_REQUESTS_VERIFY_SSL = env.bool(
'EXTERNAL_REQUESTS_VERIFY_SSL', 'EXTERNAL_REQUESTS_VERIFY_SSL',
default=True 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') artist = factory.SelfAttribute('album.artist')
position = 1 position = 1
tags = ManyToManyFromList('tags') tags = ManyToManyFromList('tags')
class Meta: class Meta:
model = 'music.Track' model = 'music.Track'
@ -57,6 +58,9 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
model = 'music.TrackFile' model = 'music.TrackFile'
class Params: class Params:
in_place = factory.Trait(
audio_file=None,
)
federation = factory.Trait( federation = factory.Trait(
audio_file=None, audio_file=None,
library_track=factory.SubFactory(LibraryTrackFactory), library_track=factory.SubFactory(LibraryTrackFactory),
@ -105,6 +109,10 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
status='finished', status='finished',
track_file=factory.SubFactory(TrackFileFactory), track_file=factory.SubFactory(TrackFileFactory),
) )
in_place = factory.Trait(
status='finished',
audio_file=None,
)
@registry.register(name='music.FileImportJob') @registry.register(name='music.FileImportJob')

View File

@ -1,5 +1,6 @@
import mutagen from django import forms
import arrow import arrow
import mutagen
NODEFAULT = object() NODEFAULT = object()
@ -50,6 +51,13 @@ def convert_track_number(v):
except (ValueError, AttributeError, IndexError): except (ValueError, AttributeError, IndexError):
pass pass
VALIDATION = {
'musicbrainz_artistid': forms.UUIDField(),
'musicbrainz_albumid': forms.UUIDField(),
'musicbrainz_recordingid': forms.UUIDField(),
}
CONF = { CONF = {
'OggVorbis': { 'OggVorbis': {
'getter': lambda f, k: f[k][0], 'getter': lambda f, k: f[k][0],
@ -146,4 +154,7 @@ class Metadata(object):
converter = field_conf.get('to_application') converter = field_conf.get('to_application')
if converter: if converter:
v = converter(v) v = converter(v)
field = VALIDATION.get(key)
if field:
v = field.to_python(v)
return 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 = models.ForeignKey(
Track, related_name='files', on_delete=models.CASCADE) Track, related_name='files', on_delete=models.CASCADE)
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255) 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) creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True) modification_date = models.DateTimeField(auto_now=True)
duration = models.IntegerField(null=True, blank=True) duration = models.IntegerField(null=True, blank=True)
@ -463,6 +463,26 @@ class TrackFile(models.Model):
self.mimetype = utils.guess_mimetype(self.audio_file) self.mimetype = utils.guess_mimetype(self.audio_file)
return super().save(**kwargs) 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 = ( IMPORT_STATUS_CHOICES = (
('pending', 'Pending'), ('pending', 'Pending'),
@ -507,6 +527,8 @@ class ImportBatch(models.Model):
def update_status(self): def update_status(self):
old_status = self.status old_status = self.status
self.status = utils.compute_status(self.jobs.all()) self.status = utils.compute_status(self.jobs.all())
if self.status == old_status:
return
self.save(update_fields=['status']) self.save(update_fields=['status'])
if self.status != old_status and self.status == 'finished': if self.status != old_status and self.status == 'finished':
from . import tasks from . import tasks

View File

@ -71,7 +71,7 @@ def import_track_from_remote(library_track):
library_track.title, artist=artist, album=album) 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) from_file = bool(import_job.audio_file)
mbid = import_job.mbid mbid = import_job.mbid
acoustid_track_id = None 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) track = import_track_data_from_path(import_job.audio_file.path)
elif import_job.library_track: elif import_job.library_track:
track = import_track_from_remote(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: else:
raise ValueError( raise ValueError(
'Not enough data to process import, ' 'Not enough data to process import, '
@ -123,7 +126,7 @@ def _do_import(import_job, replace, use_acoustid=True):
else: else:
# no downloading, we hotlink # no downloading, we hotlink
pass pass
else: elif import_job.audio_file:
track_file.download_file() track_file.download_file()
track_file.save() track_file.save()
import_job.status = 'finished' import_job.status = 'finished'
@ -133,7 +136,7 @@ def _do_import(import_job, replace, use_acoustid=True):
import_job.audio_file.delete() import_job.audio_file.delete()
import_job.save() import_job.save()
return track.pk return track_file
@celery.app.task(name='ImportJob.run', bind=True) @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']) import_job.save(update_fields=['status'])
try: 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: except Exception as exc:
if not settings.DEBUG: if not settings.DEBUG:
try: try:

View File

@ -53,10 +53,11 @@ def guess_mimetype(f):
def compute_status(jobs): 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: if errored:
return 'errored' return 'errored'
pending = any([job.status == 'pending' for job in jobs]) pending = any([status == 'pending' for status in statuses])
if pending: if pending:
return 'pending' return 'pending'
return 'finished' return 'finished'

View File

@ -23,13 +23,14 @@ from rest_framework import permissions
from musicbrainzngs import ResponseError from musicbrainzngs import ResponseError
from funkwhale_api.common import utils as funkwhale_utils 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 ( from funkwhale_api.common.permissions import (
ConditionalAuthentication, HasModelPermission) ConditionalAuthentication, HasModelPermission)
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.federation import actors
from funkwhale_api.federation.authentication import SignatureAuthentication 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 filters
from . import forms from . import forms
@ -195,12 +196,13 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
@detail_route(methods=['get']) @detail_route(methods=['get'])
def serve(self, request, *args, **kwargs): def serve(self, request, *args, **kwargs):
queryset = models.TrackFile.objects.select_related(
'library_track',
'track__album__artist',
'track__artist',
)
try: try:
f = models.TrackFile.objects.select_related( f = queryset.get(pk=kwargs['pk'])
'library_track',
'track__album__artist',
'track__artist',
).get(pk=kwargs['pk'])
except models.TrackFile.DoesNotExist: except models.TrackFile.DoesNotExist:
return Response(status=404) return Response(status=404)
@ -213,14 +215,30 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
if library_track and not audio_file: if library_track and not audio_file:
if not library_track.audio_file: if not library_track.audio_file:
# we need to populate from cache # 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 audio_file = library_track.audio_file
file_path = '{}{}'.format(
settings.PROTECT_FILES_PATH,
audio_file.url)
mt = library_track.audio_mimetype 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() response = Response()
filename = f.filename filename = f.filename
response['X-Accel-Redirect'] = "{}{}".format( response['X-Accel-Redirect'] = file_path
settings.PROTECT_FILES_PATH,
audio_file.url)
filename = "filename*=UTF-8''{}".format( filename = "filename*=UTF-8''{}".format(
urllib.parse.quote(filename)) urllib.parse.quote(filename))
response["Content-Disposition"] = "attachment; {}".format(filename) response["Content-Disposition"] = "attachment; {}".format(filename)

View File

@ -1,11 +1,11 @@
import glob import glob
import os import os
from django.conf import settings
from django.core.files import File from django.core.files import File
from django.core.management.base import BaseCommand, CommandError 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.music import tasks
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
@ -39,7 +39,20 @@ class Command(BaseCommand):
action='store_true', action='store_true',
dest='exit_on_failure', dest='exit_on_failure',
default=False, 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( parser.add_argument(
'--no-acoustid', '--no-acoustid',
@ -54,21 +67,29 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): 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 = {} glob_kwargs = {}
if options['recursive']: if options['recursive']:
glob_kwargs['recursive'] = True glob_kwargs['recursive'] = True
try: try:
matching = glob.glob(options['path'], **glob_kwargs) matching = sorted(glob.glob(options['path'], **glob_kwargs))
except TypeError: except TypeError:
raise Exception('You need Python 3.5 to use the --recursive flag') raise Exception('You need Python 3.5 to use the --recursive flag')
self.stdout.write('This will import {} files matching this pattern: {}'.format( if options['in_place']:
len(matching), options['path'])) 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: if not matching:
raise CommandError('No file matching pattern, aborting') raise CommandError('No file matching pattern, aborting')
@ -86,6 +107,24 @@ class Command(BaseCommand):
except AssertionError: except AssertionError:
raise CommandError( raise CommandError(
'No superuser available, please provide a --username') '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']: if options['interactive']:
message = ( message = (
'Are you sure you want to do this?\n\n' 'Are you sure you want to do this?\n\n'
@ -94,27 +133,52 @@ class Command(BaseCommand):
if input(''.join(message)) != 'yes': if input(''.join(message)) != 'yes':
raise CommandError("Import cancelled.") 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' message = 'Successfully imported {} tracks'
if options['async']: if options['async']:
message = 'Successfully launched import for {} tracks' 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( self.stdout.write(
"For details, please refer to import batch #{}".format(batch.pk)) "For details, please refer to import batch #{}".format(batch.pk))
@transaction.atomic def filter_matching(self, matching, options):
def do_import(self, matching, user, options): sources = ['file://{}'.format(p) for p in matching]
message = 'Importing {}...' # 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']: if options['async']:
message = 'Launching import for {}...' message = '{i}/{total} Launching import for {path}...'
# we create an import batch binded to the user # we create an import batch binded to the user
batch = user.imports.create(source='shell')
async = options['async'] async = options['async']
import_handler = tasks.import_job_run.delay if async else tasks.import_job_run 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: 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) self.import_file(path, batch, import_handler, options)
except Exception as e: except Exception as e:
if options['exit_on_failure']: if options['exit_on_failure']:
@ -122,18 +186,19 @@ class Command(BaseCommand):
m = 'Error while importing {}: {} {}'.format( m = 'Error while importing {}: {} {}'.format(
path, e.__class__.__name__, e) path, e.__class__.__name__, e)
self.stderr.write(m) 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): def import_file(self, path, batch, import_handler, options):
job = batch.jobs.create( job = batch.jobs.create(
source='file://' + path, source='file://' + path,
) )
name = os.path.basename(path) if not options['in_place']:
with open(path, 'rb') as f: name = os.path.basename(path)
job.audio_file.save(name, File(f)) with open(path, 'rb') as f:
job.audio_file.save(name, File(f))
job.save() job.save()
utils.on_commit( import_handler(
import_handler,
import_job_id=job.pk, import_job_id=job.pk,
use_acoustid=not options['no_acoustid']) use_acoustid=not options['no_acoustid'])

View File

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

View File

@ -231,3 +231,15 @@ def test_import_batch_notifies_followers(
on_behalf_of=library_actor, on_behalf_of=library_actor,
to=[f1.actor.url] 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 datetime
import os import os
import pytest import pytest
import uuid
from funkwhale_api.music import metadata 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'), ('album', 'Peer Gynt Suite no. 1, op. 46'),
('date', datetime.date(2012, 8, 15)), ('date', datetime.date(2012, 8, 15)),
('track_number', 1), ('track_number', 1),
('musicbrainz_albumid', 'a766da8b-8336-47aa-a3ee-371cc41ccc75'), ('musicbrainz_albumid', uuid.UUID('a766da8b-8336-47aa-a3ee-371cc41ccc75')),
('musicbrainz_recordingid', 'bd21ac48-46d8-4e78-925f-d9cc2a294656'), ('musicbrainz_recordingid', uuid.UUID('bd21ac48-46d8-4e78-925f-d9cc2a294656')),
('musicbrainz_artistid', '013c8e5b-d72a-4cd3-8dee-6c64d6125823'), ('musicbrainz_artistid', uuid.UUID('013c8e5b-d72a-4cd3-8dee-6c64d6125823')),
]) ])
def test_can_get_metadata_from_ogg_file(field, value): def test_can_get_metadata_from_ogg_file(field, value):
path = os.path.join(DATA_DIR, 'test.ogg') 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'), ('album', 'You Can\'t Stop Da Funk'),
('date', datetime.date(2006, 2, 7)), ('date', datetime.date(2006, 2, 7)),
('track_number', 1), ('track_number', 1),
('musicbrainz_albumid', 'ce40cdb1-a562-4fd8-a269-9269f98d4124'), ('musicbrainz_albumid', uuid.UUID('ce40cdb1-a562-4fd8-a269-9269f98d4124')),
('musicbrainz_recordingid', 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb'), ('musicbrainz_recordingid', uuid.UUID('f269d497-1cc0-4ae4-a0c4-157ec7d73fcb')),
('musicbrainz_artistid', '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13'), ('musicbrainz_artistid', uuid.UUID('9c6bddde-6228-4d9f-ad0d-03f6fcb19e13')),
]) ])
def test_can_get_metadata_from_id3_mp3_file(field, value): def test_can_get_metadata_from_id3_mp3_file(field, value):
path = os.path.join(DATA_DIR, 'test.mp3') 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' 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( def test_can_create_import_from_federation_tracks(
factories, superuser_api_client, mocker): factories, superuser_api_client, mocker):
lts = factories['federation.LibraryTrack'].create_batch(size=5) lts = factories['federation.LibraryTrack'].create_batch(size=5)

View File

@ -2,6 +2,8 @@ import pytest
import acoustid import acoustid
import datetime import datetime
import os import os
import uuid
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import CommandError 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): 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 = { metadata = {
'artist': ['Test artist'], 'artist': ['Test artist'],
'album': ['Test album'], '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')) os.path.join(DATA_DIR, 'dummy_file.ogg'))
assert track.title == metadata['title'][0] 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.position == 4
assert track.album.title == metadata['album'][0] 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.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 == metadata['musicbrainz_artistid'][0] assert track.artist.mbid == uuid.UUID(metadata['musicbrainz_artistid'][0])
def test_management_command_requires_a_valid_username(factories, mocker): def test_management_command_requires_a_valid_username(factories, mocker):
path = os.path.join(DATA_DIR, 'dummy_file.ogg') path = os.path.join(DATA_DIR, 'dummy_file.ogg')
user = factories['users.User'](username='me') 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): with pytest.raises(CommandError):
call_command('import_files', path, username='not_me', interactive=False) call_command('import_files', path, username='not_me', interactive=False)
call_command('import_files', path, username='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): 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') user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'dummy_file.ogg') path = os.path.join(DATA_DIR, 'dummy_file.ogg')
call_command( call_command(
'import_files', 'import_files',
path, path,
username='me', username='me',
async=True, async=False,
interactive=False) interactive=False)
batch = user.imports.latest('id') 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 assert job.source == 'file://' + path
m.assert_called_once_with( m.assert_called_once_with(
music_tasks.import_job_run.delay,
import_job_id=job.pk, import_job_id=job.pk,
use_acoustid=True) use_acoustid=True)
def test_import_files_skip_acoustid(factories, mocker): 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') user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'dummy_file.ogg') path = os.path.join(DATA_DIR, 'dummy_file.ogg')
call_command( call_command(
'import_files', 'import_files',
path, path,
username='me', username='me',
async=True, async=False,
no_acoustid=True, no_acoustid=True,
interactive=False) interactive=False)
batch = user.imports.latest('id') batch = user.imports.latest('id')
job = batch.jobs.first() job = batch.jobs.first()
m.assert_called_once_with( m.assert_called_once_with(
music_tasks.import_job_run.delay,
import_job_id=job.pk, import_job_id=job.pk,
use_acoustid=False) 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): 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') user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg') path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
call_command( call_command(
'import_files', 'import_files',
path, path,
username='me', username='me',
async=True, async=False,
no_acoustid=True, no_acoustid=True,
interactive=False) interactive=False)
batch = user.imports.latest('id') batch = user.imports.latest('id')
job = batch.jobs.first() job = batch.jobs.first()
m.assert_called_once_with( 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, import_job_id=job.pk,
use_acoustid=False) 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 # If you're tweaking this file from the template, ensure you edit at least the
# following variables: # following variables:
# - DJANGO_SECRET_KEY # - DJANGO_SECRET_KEY
# - DJANGO_ALLOWED_HOSTS # - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL # - FUNKWHALE_URL
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL # - DATABASE_URL
# - CACHE_URL # - CACHE_URL
# - STATIC_ROOT # - STATIC_ROOT
# - MEDIA_ROOT # - MEDIA_ROOT
# #
# You **don't** need to update those variables on pure docker setups. # 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 # Docker only
# ----------- # -----------
@ -19,7 +24,9 @@
# (it will be interpolated in docker-compose file) # (it will be interpolated in docker-compose file)
# You can comment or ignore this if you're not using docker # You can comment or ignore this if you're not using docker
FUNKWHALE_VERSION=latest FUNKWHALE_VERSION=latest
MUSIC_DIRECTORY_PATH=/music
# End of Docker-only configuration
# General configuration # General configuration
# --------------------- # ---------------------
@ -34,6 +41,7 @@ FUNKWHALE_API_PORT=5000
# your instance # your instance
FUNKWHALE_URL=https://yourdomain.funwhale FUNKWHALE_URL=https://yourdomain.funwhale
# API/Django configuration # API/Django configuration
# Database configuration # Database configuration
@ -94,3 +102,9 @@ FEDERATION_ENABLED=True
# means anyone can subscribe to your library and import your file, # means anyone can subscribe to your library and import your file,
# use with caution. # use with caution.
FEDERATION_MUSIC_NEEDS_APPROVAL=True 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; 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 # Transcoding logic and caching
location = /transcode-auth { location = /transcode-auth {
include /etc/nginx/funkwhale_proxy.conf; include /etc/nginx/funkwhale_proxy.conf;

View File

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

View File

@ -42,6 +42,10 @@ http {
internal; internal;
alias /protected/media; alias /protected/media;
} }
location /_protected/music {
internal;
alias /music;
}
location = /transcode-auth { location = /transcode-auth {
# needed so we can authenticate transcode requests, but still # needed so we can authenticate transcode requests, but still
# cache the result # 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 If you plan to use acoustid and external imports
(e.g. with the youtube backends), you should edit the corresponding (e.g. with the youtube backends), you should edit the corresponding
settings in this interface. 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 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 The import command supports several options, and you can check the help to
`Picard <http://picard.musicbrainz.org/>`_ in order to have the best quality metadata. 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:: .. 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 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 .. _in-place-import:
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::
# this will only import ogg files at the second level In-place import
"/srv/funkwhale/data/music/*/*.ogg" ^^^^^^^^^^^^^^^
# this will only import ogg files in the fiven directory
"/srv/funkwhale/data/music/System-of-a-down/*.ogg"
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 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-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" 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: '/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 <a
@click="$store.commit('playlists/chooseTrack', null)" @click="$store.commit('playlists/chooseTrack', null)"
v-if="$store.state.auth.authenticated" v-if="$store.state.auth.authenticated"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<template> <template>
<div v-title="'Federated tracks'"> <div v-title="'Federated tracks'">
<div class="ui vertical stripe segment"> <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> <div class="ui hidden divider"></div>
<library-track-table :show-library="true"></library-track-table> <library-track-table :show-library="true"></library-track-table>
</div> </div>

View File

@ -2,10 +2,10 @@
<div class="main pusher" v-title="'Instance Timeline'"> <div class="main pusher" v-title="'Instance Timeline'">
<div class="ui vertical center aligned stripe segment"> <div class="ui vertical center aligned stripe segment">
<div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']"> <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>
<div v-else class="ui text container"> <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"> <div class="ui feed">
<component <component
class="event" 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, action: store.actions.logout,
params: {state: {}}, params: {state: {}},
expectedMutations: [ 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) }, done)
}) })
@ -107,8 +112,6 @@ describe('store/auth', () => {
action: store.actions.check, action: store.actions.check,
params: {state: {token: 'test', username: 'user'}}, params: {state: {token: 'test', username: 'user'}},
expectedMutations: [ expectedMutations: [
{ type: 'authenticated', payload: true },
{ type: 'username', payload: 'user' },
{ type: 'token', payload: 'test' } { type: 'token', payload: 'test' }
], ],
expectedActions: [ expectedActions: [
@ -131,9 +134,7 @@ describe('store/auth', () => {
action: store.actions.login, action: store.actions.login,
payload: {credentials: credentials}, payload: {credentials: credentials},
expectedMutations: [ expectedMutations: [
{ type: 'token', payload: 'test' }, { type: 'token', payload: 'test' }
{ type: 'username', payload: 'bob' },
{ type: 'authenticated', payload: true }
], ],
expectedActions: [ expectedActions: [
{ type: 'fetchProfile' } { type: 'fetchProfile' }
@ -175,13 +176,14 @@ describe('store/auth', () => {
testAction({ testAction({
action: store.actions.fetchProfile, action: store.actions.fetchProfile,
expectedMutations: [ expectedMutations: [
{ type: 'authenticated', payload: true },
{ type: 'profile', payload: profile }, { type: 'profile', payload: profile },
{ type: 'username', payload: profile.username }, { type: 'username', payload: profile.username },
{ type: 'permission', payload: {key: 'admin', status: true} } { type: 'permission', payload: {key: 'admin', status: true} }
], ],
expectedActions: [ expectedActions: [
{ type: 'favorites/fetch', payload: null, options: {root: true} }, { 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) }, done)
}) })

View File

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

View File

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

View File

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

View File

@ -1,4 +1,11 @@
// helper for testing action with expected mutations // 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) => { export const testAction = ({action, payload, params, expectedMutations, expectedActions}, done) => {
let mutationsCount = 0 let mutationsCount = 0
let actionsCount = 0 let actionsCount = 0