diff --git a/.env.dev b/.env.dev index 3f904078c36d0a5fa1392356d83a5e1e0d493204..c09262509296ad91911a4bf75989e119117c9315 100644 --- a/.env.dev +++ b/.env.dev @@ -9,3 +9,4 @@ FUNKWHALE_HOSTNAME=localhost FUNKWHALE_PROTOCOL=http PYTHONDONTWRITEBYTECODE=true WEBPACK_DEVSERVER_PORT=8080 +MUSIC_DIRECTORY_PATH=/music diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 217047794ba8c2974dd1fa69396674cd11aea726..792c7e1b25da49201e54553f1b4416314e23c904 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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: diff --git a/CHANGELOG b/CHANGELOG index b230b1556bf7e35ceb38e4529d360a94a5d835bd..c2d9be1a7685c91119632865dc6de24e0fb866a1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) ------------------ diff --git a/api/config/settings/common.py b/api/config/settings/common.py index a972ec7effc9e5696f94833c19c52096ff307f14..5e895bea553ed9f5e346057793d64d90913872f0 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -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) diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py index ea7ff64dfa0cb154c871089e75c70cf9c19a6e1a..bc0c74a2d0c108330e274d71522bfed9cccc8554 100644 --- a/api/funkwhale_api/music/factories.py +++ b/api/funkwhale_api/music/factories.py @@ -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') diff --git a/api/funkwhale_api/music/metadata.py b/api/funkwhale_api/music/metadata.py index 3748d55730faf8188d56828bda3afd67dd219393..a200697834d1f68aff4291c5fcf9857ad401a1cd 100644 --- a/api/funkwhale_api/music/metadata.py +++ b/api/funkwhale_api/music/metadata.py @@ -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 diff --git a/api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py b/api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py new file mode 100644 index 0000000000000000000000000000000000000000..6b0230d505235f0f920fc4b84e7adf735601f990 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0025_auto_20180419_2023.py @@ -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), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 4ec3ff4274efca8c6ad5f2e370ce90eda6b10141..98fc1965b51dff90a9aaae1717e5dc7ca5032538 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -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 diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index bc5ab94f0ae7a56470f42e3c705c40ff11d86053..4b9e15fc9e23a5f18bfe4159730f22959514e7cf 100644 --- a/api/funkwhale_api/music/tasks.py +++ b/api/funkwhale_api/music/tasks.py @@ -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: diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index af0e59ab497e63ea06be2a50ecf9df0651072587..7a851f7cc35e17681293a9e3a1c24d6cc1e64998 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -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' diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index e8ace1b3ab676c2cc6b76f134dbae6668ac2d655..d03b55e50fb16506102e43d2429fbae9b9fc172b 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -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) diff --git a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py index dbc01289f1206c18a15a691d2fb8025695155b17..a2757c692bd4607b0779ba112e4768a5881d8770 100644 --- a/api/funkwhale_api/providers/audiofile/management/commands/import_files.py +++ b/api/funkwhale_api/providers/audiofile/management/commands/import_files.py @@ -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']) diff --git a/api/funkwhale_api/providers/audiofile/tasks.py b/api/funkwhale_api/providers/audiofile/tasks.py index bc18456c306731eee04e596b53b86c29d1cf87e5..40114c8774abfa2f18e520dd15b19be9f382cb58 100644 --- a/api/funkwhale_api/providers/audiofile/tasks.py +++ b/api/funkwhale_api/providers/audiofile/tasks.py @@ -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 diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py index 2f22ed69ad8db3ffb6ec098d606b194fbdb8ab3e..65e0242fb013785b0eb21e29ac9259512b281392 100644 --- a/api/tests/music/test_import.py +++ b/api/tests/music/test_import.py @@ -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' diff --git a/api/tests/music/test_metadata.py b/api/tests/music/test_metadata.py index 5df2dbcf117965cf41563a6d028cbe804c3e0fd8..342bc99b8115cf0646da9c9e61c10fe645738ec3 100644 --- a/api/tests/music/test_metadata.py +++ b/api/tests/music/test_metadata.py @@ -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') diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 81f34fbe9d95d41e198ef783746df65dcdbb0561..95c56d914893fb775a2325515b06662f19a32859 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -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) diff --git a/api/tests/test_import_audio_file.py b/api/tests/test_import_audio_file.py index 67263e66d0c56309bb21f344274277b7d4464165..8217ffa0b27a26ac99183314f87d69feced35e7d 100644 --- a/api/tests/test_import_audio_file.py +++ b/api/tests/test_import_audio_file.py @@ -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=False, + no_acoustid=True, + interactive=False) + batch = user.imports.latest('id') + job = batch.jobs.first() + m.assert_called_once_with( + 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=True, + 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( - music_tasks.import_job_run.delay, import_job_id=job.pk, use_acoustid=False) diff --git a/changes/changelog.d/124.bugfix b/changes/changelog.d/124.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..b1e104a438b776a035818bb0bf440dc6d7caa5f8 --- /dev/null +++ b/changes/changelog.d/124.bugfix @@ -0,0 +1 @@ +Reset all sensitive front-end data on logout (#124) diff --git a/changes/changelog.d/142.enhancement b/changes/changelog.d/142.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..9784f540339d4afc8c69291f2cf301bfd3435763 --- /dev/null +++ b/changes/changelog.d/142.enhancement @@ -0,0 +1 @@ +Increased max_length on TrackFile.source, this will help when importing files with a really long path (#142) diff --git a/changes/changelog.d/144.enhancement b/changes/changelog.d/144.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..2c238ed216ec7c3a2d58386821c32c693854e88f --- /dev/null +++ b/changes/changelog.d/144.enhancement @@ -0,0 +1 @@ +Better file import performance and error handling (#144) diff --git a/changes/changelog.d/153.feature b/changes/changelog.d/153.feature new file mode 100644 index 0000000000000000000000000000000000000000..1a2e1b92e51def5b63115ed5adcdd3006d394c17 --- /dev/null +++ b/changes/changelog.d/153.feature @@ -0,0 +1 @@ +Can now import files in-place from the CLI importe (#155) diff --git a/changes/changelog.d/155.bugfix b/changes/changelog.d/155.bugfix new file mode 100644 index 0000000000000000000000000000000000000000..2252d56d7d2a0c5db5735cc8d00195746ab5b772 --- /dev/null +++ b/changes/changelog.d/155.bugfix @@ -0,0 +1 @@ +Fixed broken playlist modal after login (#155) diff --git a/changes/changelog.d/163.enhancement b/changes/changelog.d/163.enhancement new file mode 100644 index 0000000000000000000000000000000000000000..89225eb9cb32f0e148ec5030f77b27f507859b4e --- /dev/null +++ b/changes/changelog.d/163.enhancement @@ -0,0 +1 @@ +Avoid downloading audio files multiple times from remote libraries (#163) diff --git a/changes/changelog.d/165.doc b/changes/changelog.d/165.doc index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..4825af09bfd59db9e2ace6844962c738d01524f6 100644 --- a/changes/changelog.d/165.doc +++ b/changes/changelog.d/165.doc @@ -0,0 +1 @@ +Better documentation for hardware requirements and memory usage (#165) diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index 9e9938500823e538cced62b88280f7ef67824ccb..f33b06876f55673b6bbfe5363defe9dca444443c 100644 --- a/deploy/env.prod.sample +++ b/deploy/env.prod.sample @@ -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= diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 1c304b4938892bf87ba50ab05f2394784b020248..b3a4c6aaf762830b533774689cb26266ca6b65e8 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -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; diff --git a/dev.yml b/dev.yml index 2df7b44e60100b4812db940f45fb8baf14da2544..264fc953483d1dec5b19b30d3ade754efb47691d 100644 --- a/dev.yml +++ b/dev.yml @@ -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: diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index e832a5ae3ee5b4d96833bd1cf5bf23fee6d5033f..38c3de6c7e41369aa98bf6e3f21ad84a8d0c0a4d 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -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 diff --git a/docs/configuration.rst b/docs/configuration.rst index 5883a2d17e6d99ba5c34e0a6693e7a8537b34942..c0de76f56a1e9d7b1d8eaeb0d9da8dea0c3257a3 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -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 diff --git a/docs/importing-music.rst b/docs/importing-music.rst index f09eea7b184d86bd465f5ba40c4c49343066da31..97dd1385485c18eab10fe805e49ff829140d3654 100644 --- a/docs/importing-music.rst +++ b/docs/importing-music.rst @@ -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: + +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` - # 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" +.. 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 ^^^^^^^^^^^^^^^^^^^ diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 602882123dde19fdceff68cd46f7d8f7f45c5b5c..97240bba4a09b2e6c61351a31cd8922fef666866 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -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" diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue index 03e36ee8846e64c2da147e5cb5e66b0e97b1d94f..404948dc0793cfce4a9515bfde6b76c95afa17a9 100644 --- a/front/src/components/playlists/PlaylistModal.vue +++ b/front/src/components/playlists/PlaylistModal.vue @@ -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> diff --git a/front/src/store/auth.js b/front/src/store/auth.js index e72e1968f52f1ca3593abfbbdabf4e06fda7b1d5..b1753404f9be65c2d5fe2a067607d83ef45d4d6a 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -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}) diff --git a/front/src/store/favorites.js b/front/src/store/favorites.js index a4f85b235daa36567f16528d8396d259e63b1db0..b7e789511217726e74412591d18233e6d25ed762 100644 --- a/front/src/store/favorites.js +++ b/front/src/store/favorites.js @@ -20,6 +20,10 @@ export default { } } state.count = state.tracks.length + }, + reset (state) { + state.tracks = [] + state.count = 0 } }, getters: { diff --git a/front/src/store/player.js b/front/src/store/player.js index d849b7b56ceaa80fa93ab30cd2b5654fbcc33a9f..ed437c3f0220d84ed26693c94a8036c38c756b64 100644 --- a/front/src/store/player.js +++ b/front/src/store/player.js @@ -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) diff --git a/front/src/store/playlists.js b/front/src/store/playlists.js index b3ed3ab235bf09456a4b7cc9d7129963443488ca..d0e144d803eec7917ea67ebc486f1f95c29e8e83 100644 --- a/front/src/store/playlists.js +++ b/front/src/store/playlists.js @@ -17,6 +17,11 @@ export default { }, showModal (state, value) { state.showModal = value + }, + reset (state) { + state.playlists = [] + state.modalTrack = null + state.showModal = false } }, actions: { diff --git a/front/src/store/queue.js b/front/src/store/queue.js index 6a26fa1e9a0dc70609b0ea6fcba6e5b11745f277..2890dd1e8f89ad8f83629499b225dae020ae579d 100644 --- a/front/src/store/queue.js +++ b/front/src/store/queue.js @@ -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 }, diff --git a/front/src/store/radios.js b/front/src/store/radios.js index e95db512643d297fa08ecca16df30e8004739e7c..49bbd4f9410dff015721f57073c4d95c58d7146a 100644 --- a/front/src/store/radios.js +++ b/front/src/store/radios.js @@ -26,6 +26,10 @@ export default { } }, mutations: { + reset (state) { + state.running = false + state.current = false + }, current: (state, value) => { state.current = value }, diff --git a/front/src/views/federation/Base.vue b/front/src/views/federation/Base.vue index 7958bb36b65332b840111cba5ab37c5e2a7962b0..951fe9f0f1c49abe2d43274151008f9d97113439 100644 --- a/front/src/views/federation/Base.vue +++ b/front/src/views/federation/Base.vue @@ -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> diff --git a/front/src/views/federation/LibraryDetail.vue b/front/src/views/federation/LibraryDetail.vue index 20250e333d866941e7605667b1a3541b7095e716..bd2e63c4d9c4644c66d625bd8218dcdbf864ef85 100644 --- a/front/src/views/federation/LibraryDetail.vue +++ b/front/src/views/federation/LibraryDetail.vue @@ -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> diff --git a/front/src/views/federation/LibraryFollowersList.vue b/front/src/views/federation/LibraryFollowersList.vue index 8ca120e8b54e3178d16dfcd7cd6f4af9468ddf1d..ce79e478747267637a418cb31f979a0421d1f98a 100644 --- a/front/src/views/federation/LibraryFollowersList.vue +++ b/front/src/views/federation/LibraryFollowersList.vue @@ -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> diff --git a/front/src/views/federation/LibraryList.vue b/front/src/views/federation/LibraryList.vue index 43800a72e4afd182b3b925997f842f11198bf416..7b0b259412a3665bb4c4c04f1ce62444872439dc 100644 --- a/front/src/views/federation/LibraryList.vue +++ b/front/src/views/federation/LibraryList.vue @@ -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> diff --git a/front/src/views/federation/LibraryTrackList.vue b/front/src/views/federation/LibraryTrackList.vue index 42526b6e422d2325c480024e035d9907a28e9fe0..b9a78527f16eac6eae0fb6365ede85cd35f0248f 100644 --- a/front/src/views/federation/LibraryTrackList.vue +++ b/front/src/views/federation/LibraryTrackList.vue @@ -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> diff --git a/front/src/views/instance/Timeline.vue b/front/src/views/instance/Timeline.vue index f4ddedfb72e287235081cc8d86c5f5a388197c79..8b34798cde0e6f68c7a0f6cc9e19514bf2bc649c 100644 --- a/front/src/views/instance/Timeline.vue +++ b/front/src/views/instance/Timeline.vue @@ -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" diff --git a/front/test/unit/specs/components/common.spec.js b/front/test/unit/specs/components/common.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1af4144ca858bb1c264d43cc602994569ce8fc8b --- /dev/null +++ b/front/test/unit/specs/components/common.spec.js @@ -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') + }) +}) diff --git a/front/test/unit/specs/store/auth.spec.js b/front/test/unit/specs/store/auth.spec.js index 518dc10d4db29f680567d6c996853086cd6b4516..3d175e9f9fa0e31b3b51b79314fe182e0516549c 100644 --- a/front/test/unit/specs/store/auth.spec.js +++ b/front/test/unit/specs/store/auth.spec.js @@ -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) }) diff --git a/front/test/unit/specs/store/player.spec.js b/front/test/unit/specs/store/player.spec.js index b55fb010d08576bfe9e029cd6eb8e130af81570f..1e6c9b33a5fd3c2c43911e4c8ae26e78bd239558 100644 --- a/front/test/unit/specs/store/player.spec.js +++ b/front/test/unit/specs/store/player.spec.js @@ -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} }, diff --git a/front/test/unit/specs/store/queue.spec.js b/front/test/unit/specs/store/queue.spec.js index 2bc5cde4efec16a0cfdcf40ce36b1385780b93a1..0df7608e75c5d3bdb03c0b99d51fb5d6a75f7f96 100644 --- a/front/test/unit/specs/store/queue.spec.js +++ b/front/test/unit/specs/store/queue.spec.js @@ -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: [ diff --git a/front/test/unit/specs/store/radios.spec.js b/front/test/unit/specs/store/radios.spec.js index 6de6b8dd94858f34ee07d522d1e3d2ab36185cc0..3a8c48f04ec942ee8ecf8d71c8a64a4e009987ab 100644 --- a/front/test/unit/specs/store/radios.spec.js +++ b/front/test/unit/specs/store/radios.spec.js @@ -97,6 +97,5 @@ describe('store/radios', () => { expectedActions: [] }, done) }) - }) }) diff --git a/front/test/unit/utils.js b/front/test/unit/utils.js index 233ee982e5221e0997da8bd29d33cfac17a113e3..6471fc97f710385ff20dcfe2fdd4ee9ac7b9ec36 100644 --- a/front/test/unit/utils.js +++ b/front/test/unit/utils.js @@ -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