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..6a0f4b9d89ef23ee59872c3f0ea7d19225e16182 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: @@ -81,9 +83,9 @@ build_front: paths: - front/dist/ only: - - tags - - master - - develop + - tags@funkwhale/funkwhale + - master@funkwhale/funkwhale + - develop@funkwhale/funkwhale tags: - docker @@ -100,7 +102,7 @@ pages: paths: - public only: - - develop + - develop@funkwhale/funkwhale tags: - docker @@ -114,7 +116,7 @@ docker_develop: - docker build -t $IMAGE . - docker push $IMAGE only: - - develop + - develop@funkwhale/funkwhale tags: - dind @@ -128,9 +130,9 @@ build_api: - api script: echo Done! only: - - tags - - master - - develop + - tags@funkwhale/funkwhale + - master@funkwhale/funkwhale + - develop@funkwhale/funkwhale docker_release: @@ -144,6 +146,6 @@ docker_release: - docker push $IMAGE - docker push $IMAGE_LATEST only: - - tags + - tags@funkwhale/funkwhale tags: - dind diff --git a/CHANGELOG b/CHANGELOG index b230b1556bf7e35ceb38e4529d360a94a5d835bd..c56d58836179a84d0af30ad333764a55d800a136 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,88 @@ Changelog .. towncrier +0.10 (2018-04-23) +----------------- + +Features: + +- Can now import files in-place from the CLI importer (#155) + + +Enhancements: + +- Avoid downloading audio files multiple times from remote libraries (#163) +- Better file import performance and error handling (#144) +- Import job and batch API and front-end have been improved with better + performance, pagination and additional filters (#171) +- Increased max_length on TrackFile.source, this will help when importing files + with a really long path (#142) +- Player is back in Queue tab (#150) + + +Bugfixes: + +- Fail graciously when AP representation includes a null_value for mediaType +- Fix sidebar tabs not showing under small resolution under Chrome (#173) +- Fixed broken login due to badly configured Axios (#172) +- Fixed broken playlist modal after login (#155) +- Fixed queue reorder or track deletion restarting currently playing track + (#151) +- Radio will now append new track if you delete the last track in queue (#145) +- Reset all sensitive front-end data on logout (#124) +- Typos/not showing text due to i18n work (#175) + + +Documentation: + +- Better documentation for hardware requirements and memory usage (#165) + + +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 <https://docs.funkwhale.audio/importing-music.html#in-place-import>`_ +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:: + + # this is the path in the container + MUSIC_DIRECTORY_PATH=/music + # this is the path on the host + 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..de1d653cb91fa3518273b8b103ed74fdee4b9259 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -390,6 +390,12 @@ REST_FRAMEWORK = { ATOMIC_REQUESTS = False USE_X_FORWARDED_HOST = True USE_X_FORWARDED_PORT = True + +# Wether we should use Apache, Nginx (or other) headers when serving audio files +# Default to Nginx +REVERSE_PROXY_TYPE = env('REVERSE_PROXY_TYPE', default='nginx') +assert REVERSE_PROXY_TYPE in ['apache2', 'nginx'], 'Unsupported REVERSE_PROXY_TYPE' + # Wether we should check user permission before serving audio files (meaning # return an obfuscated url) # This require a special configuration on the reverse proxy side @@ -441,3 +447,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/__init__.py b/api/funkwhale_api/__init__.py index f3a544e460a55bd0399a00d7ec62f70aed20f12c..596926919170b9efbf5914a51010f9da8f67751d 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -__version__ = '0.9.1' +__version__ = '0.10' __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 735a101b40e3e615602c2586ef3bcb8cded638cf..00bb7d45b0b1b98176d74f58075866392a53898c 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -708,23 +708,7 @@ class AudioSerializer(serializers.Serializer): except (KeyError, TypeError): raise serializers.ValidationError('Missing mediaType') - if not media_type.startswith('audio/'): - raise serializers.ValidationError('Invalid mediaType') - - return url - - def validate_url(self, v): - try: - url = v['href'] - except (KeyError, TypeError): - raise serializers.ValidationError('Missing href') - - try: - media_type = v['mediaType'] - except (KeyError, TypeError): - raise serializers.ValidationError('Missing mediaType') - - if not media_type.startswith('audio/'): + if not media_type or not media_type.startswith('audio/'): raise serializers.ValidationError('Invalid mediaType') return v 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/filters.py b/api/funkwhale_api/music/filters.py index fbea3735a267188485bfe236ecbd39ae56ae2e39..752422e75e64aae20f19bc46ab00cd4678c220d6 100644 --- a/api/funkwhale_api/music/filters.py +++ b/api/funkwhale_api/music/filters.py @@ -2,6 +2,7 @@ from django.db.models import Count from django_filters import rest_framework as filters +from funkwhale_api.common import fields from . import models @@ -28,6 +29,39 @@ class ArtistFilter(ListenableMixin): } +class ImportBatchFilter(filters.FilterSet): + q = fields.SearchFilter(search_fields=[ + 'submitted_by__username', + 'source', + ]) + + class Meta: + model = models.ImportBatch + fields = { + 'status': ['exact'], + 'source': ['exact'], + 'submitted_by': ['exact'], + } + + +class ImportJobFilter(filters.FilterSet): + q = fields.SearchFilter(search_fields=[ + 'batch__submitted_by__username', + 'source', + ]) + + class Meta: + model = models.ImportJob + fields = { + 'batch': ['exact'], + 'batch__status': ['exact'], + 'batch__source': ['exact'], + 'batch__submitted_by': ['exact'], + 'status': ['exact'], + 'source': ['exact'], + } + + class AlbumFilter(ListenableMixin): listenable = filters.BooleanFilter(name='_', method='filter_listenable') 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/serializers.py b/api/funkwhale_api/music/serializers.py index b5f69eb1db866225fca4cc147e71a08901194dd8..b9ecfc50dcd982e109e369cc611656beba08c4b6 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -6,6 +6,7 @@ from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation.models import LibraryTrack from funkwhale_api.federation.serializers import AP_CONTEXT +from funkwhale_api.users.serializers import UserBasicSerializer from . import models @@ -90,6 +91,7 @@ class TrackSerializerNested(LyricsMixin): files = TrackFileSerializer(many=True, read_only=True) album = SimpleAlbumSerializer(read_only=True) tags = TagSerializer(many=True, read_only=True) + class Meta: model = models.Track fields = ('id', 'mbid', 'title', 'artist', 'files', 'album', 'tags', 'lyrics') @@ -108,6 +110,7 @@ class AlbumSerializerNested(serializers.ModelSerializer): class ArtistSerializerNested(serializers.ModelSerializer): albums = AlbumSerializerNested(many=True, read_only=True) tags = TagSerializer(many=True, read_only=True) + class Meta: model = models.Artist fields = ('id', 'mbid', 'name', 'albums', 'tags') @@ -121,18 +124,43 @@ class LyricsSerializer(serializers.ModelSerializer): class ImportJobSerializer(serializers.ModelSerializer): track_file = TrackFileSerializer(read_only=True) + class Meta: model = models.ImportJob - fields = ('id', 'mbid', 'batch', 'source', 'status', 'track_file', 'audio_file') + fields = ( + 'id', + 'mbid', + 'batch', + 'source', + 'status', + 'track_file', + 'audio_file') read_only_fields = ('status', 'track_file') class ImportBatchSerializer(serializers.ModelSerializer): - jobs = ImportJobSerializer(many=True, read_only=True) + submitted_by = UserBasicSerializer(read_only=True) + class Meta: model = models.ImportBatch - fields = ('id', 'jobs', 'status', 'creation_date', 'import_request') - read_only_fields = ('creation_date',) + fields = ( + 'id', + 'submitted_by', + 'source', + 'status', + 'creation_date', + 'import_request') + read_only_fields = ( + 'creation_date', 'submitted_by', 'source') + + def to_representation(self, instance): + repr = super().to_representation(instance) + try: + repr['job_count'] = instance.job_count + except AttributeError: + # Queryset was not annotated + pass + return repr class TrackActivitySerializer(activity_serializers.ModelSerializer): diff --git a/api/funkwhale_api/music/tasks.py b/api/funkwhale_api/music/tasks.py index bc5ab94f0ae7a56470f42e3c705c40ff11d86053..f2244d78527c5feff7248b9a40c083ea71498891 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,8 @@ def _do_import(import_job, replace, use_acoustid=True): else: # no downloading, we hotlink pass - else: + elif not import_job.audio_file and not import_job.source.startswith('file://'): + # not an implace import, and we have a source, so let's download it track_file.download_file() track_file.save() import_job.status = 'finished' @@ -133,7 +137,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 +151,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..af063da4679e7df8c4e295dc19dbd03812a1c810 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.conf import settings from django.db import models, transaction from django.db.models.functions import Length +from django.db.models import Count from django.http import StreamingHttpResponse from django.urls import reverse from django.utils.decorators import method_decorator @@ -23,13 +24,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 @@ -98,14 +100,14 @@ class ImportBatchViewSet( mixins.RetrieveModelMixin, viewsets.GenericViewSet): queryset = ( - models.ImportBatch.objects.all() - .prefetch_related('jobs__track_file') - .order_by('-creation_date')) + models.ImportBatch.objects + .select_related() + .order_by('-creation_date') + .annotate(job_count=Count('jobs')) + ) serializer_class = serializers.ImportBatchSerializer permission_classes = (permissions.DjangoModelPermissions, ) - - def get_queryset(self): - return super().get_queryset().filter(submitted_by=self.request.user) + filter_class = filters.ImportBatchFilter def perform_create(self, serializer): serializer.save(submitted_by=self.request.user) @@ -118,13 +120,30 @@ class ImportJobPermission(HasModelPermission): class ImportJobViewSet( mixins.CreateModelMixin, + mixins.ListModelMixin, viewsets.GenericViewSet): - queryset = (models.ImportJob.objects.all()) + queryset = (models.ImportJob.objects.all().select_related()) serializer_class = serializers.ImportJobSerializer permission_classes = (ImportJobPermission, ) + filter_class = filters.ImportJobFilter - def get_queryset(self): - return super().get_queryset().filter(batch__submitted_by=self.request.user) + @list_route(methods=['get']) + def stats(self, request, *args, **kwargs): + qs = models.ImportJob.objects.all() + filterset = filters.ImportJobFilter(request.GET, queryset=qs) + qs = filterset.qs + qs = qs.values('status').order_by('status') + qs = qs.annotate(status_count=Count('status')) + + data = {} + for row in qs: + data[row['status']] = row['status_count'] + + for s, _ in models.IMPORT_STATUS_CHOICES: + data.setdefault(s, 0) + + data['count'] = sum([v for v in data.values()]) + return Response(data) def perform_create(self, serializer): source = 'file://' + serializer.validated_data['audio_file'].name @@ -135,7 +154,8 @@ class ImportJobViewSet( ) -class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): +class TrackViewSet( + TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): """ A simple ViewSet for viewing and editing accounts. """ @@ -185,6 +205,25 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): return Response(serializer.data) +def get_file_path(audio_file): + t = settings.REVERSE_PROXY_TYPE + if t == 'nginx': + # we have to use the internal locations + try: + path = audio_file.url + except AttributeError: + # a path was given + path = '/music' + audio_file + return settings.PROTECT_FILES_PATH + path + if t == 'apache2': + try: + path = audio_file.path + except AttributeError: + # a path was given + path = audio_file + return path + + class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): queryset = (models.TrackFile.objects.all().order_by('-id')) serializer_class = serializers.TrackFileSerializer @@ -195,12 +234,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 +253,29 @@ 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 = get_file_path(audio_file) mt = library_track.audio_mimetype + elif audio_file: + file_path = get_file_path(audio_file) + elif f.source and f.source.startswith('file://'): + file_path = get_file_path(f.serve_from_source_path) response = Response() filename = f.filename - response['X-Accel-Redirect'] = "{}{}".format( - settings.PROTECT_FILES_PATH, - audio_file.url) + mapping = { + 'nginx': 'X-Accel-Redirect', + 'apache2': 'X-Sendfile', + } + file_header = mapping[settings.REVERSE_PROXY_TYPE] + response[file_header] = 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/requirements/base.txt b/api/requirements/base.txt index b66e297a9942524b02df71fdcc67c81225e62c1e..ac058656639aa810e334721e75a548c34704e178 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -33,6 +33,7 @@ musicbrainzngs==0.6 youtube_dl>=2017.12.14 djangorestframework>=3.7,<3.8 djangorestframework-jwt>=1.11,<1.12 +oauth2client<4 google-api-python-client>=1.6,<1.7 arrow>=0.12,<0.13 persisting-theory>=0.2,<0.3 diff --git a/api/tests/music/test_api.py b/api/tests/music/test_api.py index 606720e133928ecda0d2bf22e9440e6b1c9eeb07..cc6fe644b6a4acb7a654453332021dfdc3803013 100644 --- a/api/tests/music/test_api.py +++ b/api/tests/music/test_api.py @@ -181,30 +181,6 @@ def test_can_import_whole_artist( assert job.source == row['source'] -def test_user_can_query_api_for_his_own_batches( - superuser_api_client, factories): - factories['music.ImportJob']() - job = factories['music.ImportJob']( - batch__submitted_by=superuser_api_client.user) - url = reverse('api:v1:import-batches-list') - - response = superuser_api_client.get(url) - results = response.data - assert results['count'] == 1 - assert results['results'][0]['jobs'][0]['mbid'] == job.mbid - - -def test_user_cannnot_access_other_batches( - superuser_api_client, factories): - factories['music.ImportJob']() - job = factories['music.ImportJob']() - url = reverse('api:v1:import-batches-list') - - response = superuser_api_client.get(url) - results = response.data - assert results['count'] == 0 - - def test_user_can_create_an_empty_batch(superuser_api_client, factories): url = reverse('api:v1:import-batches-list') response = superuser_api_client.post(url) 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..5d7589af08c16efe1b69b72d48b37e4c77500633 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -76,6 +76,31 @@ def test_can_serve_track_file_as_remote_library_deny_not_following( assert response.status_code == 403 +def test_serve_file_apache(factories, api_client, settings): + settings.PROTECT_AUDIO_FILES = False + settings.REVERSE_PROXY_TYPE = 'apache2' + tf = factories['music.TrackFile']() + response = api_client.get(tf.path) + + assert response.status_code == 200 + assert response['X-Sendfile'] == tf.audio_file.path + + +def test_serve_file_apache_in_place(factories, api_client, settings): + settings.PROTECT_AUDIO_FILES = False + settings.REVERSE_PROXY_TYPE = 'apache2' + settings.MUSIC_DIRECTORY_PATH = '/music' + settings.MUSIC_DIRECTORY_SERVE_PATH = '/host/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-Sendfile'] == '/host/music/test.ogg' + + def test_can_proxy_remote_track( factories, settings, api_client, r_mock): settings.PROTECT_AUDIO_FILES = False @@ -93,6 +118,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) @@ -109,3 +153,46 @@ def test_can_create_import_from_federation_tracks( assert batch.jobs.count() == 5 for i, job in enumerate(batch.jobs.all()): assert job.library_track == lts[i] + + +def test_can_list_import_jobs(factories, superuser_api_client): + job = factories['music.ImportJob']() + url = reverse('api:v1:import-jobs-list') + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data['results'][0]['id'] == job.pk + + +def test_import_job_stats(factories, superuser_api_client): + job1 = factories['music.ImportJob'](status='pending') + job2 = factories['music.ImportJob'](status='errored') + + url = reverse('api:v1:import-jobs-stats') + response = superuser_api_client.get(url) + expected = { + 'errored': 1, + 'pending': 1, + 'finished': 0, + 'skipped': 0, + 'count': 2, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_import_job_stats_filter(factories, superuser_api_client): + job1 = factories['music.ImportJob'](status='pending') + job2 = factories['music.ImportJob'](status='errored') + + url = reverse('api:v1:import-jobs-stats') + response = superuser_api_client.get(url, {'batch': job1.batch.pk}) + expected = { + 'errored': 0, + 'pending': 1, + 'finished': 0, + 'skipped': 0, + 'count': 1, + } + assert response.status_code == 200 + assert response.data == expected 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/deploy/docker-compose.yml b/deploy/docker-compose.yml index cc4f357cad229768d1b98d8b104c7aa8f2c691d5..3bcd2f6d508a7b41747ba10ee85306adf9aa6619 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -20,6 +20,14 @@ services: restart: unless-stopped image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest} env_file: .env + # Celery workers handle background tasks (such file imports or federation + # messaging). The more processes a worker gets, the more tasks + # can be processed in parallel. However, more processes also means + # a bigger memory footprint. + # By default, a worker will span a number of process equal to your number + # of CPUs. You can adjust this, by explicitly setting the --concurrency + # flag: + # celery -A funkwhale_api.taskapp worker -l INFO --concurrency=4 command: celery -A funkwhale_api.taskapp worker -l INFO links: - postgres diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index 9e9938500823e538cced62b88280f7ef67824ccb..54f2e1ef08192d5a181d04bb69ad69b0848f2faa 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,11 @@ FUNKWHALE_API_PORT=5000 # your instance FUNKWHALE_URL=https://yourdomain.funwhale +# Depending on the reverse proxy used in front of your funkwhale instance, +# the API will use different kind of headers to serve audio files +# Allowed values: nginx, apache2 +REVERSE_PROXY_TYPE=nginx + # API/Django configuration # Database configuration @@ -94,3 +106,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/funkwhale-worker.service b/deploy/funkwhale-worker.service index cb3c883070f13ec0052694a27d34719f908791e3..4df60b5e98ea5b997f882984624a3c79ebe6f01b 100644 --- a/deploy/funkwhale-worker.service +++ b/deploy/funkwhale-worker.service @@ -8,6 +8,14 @@ User=funkwhale # adapt this depending on the path of your funkwhale installation WorkingDirectory=/srv/funkwhale/api EnvironmentFile=/srv/funkwhale/config/.env +# Celery workers handle background tasks (such file imports or federation +# messaging). The more processes a worker gets, the more tasks +# can be processed in parallel. However, more processes also means +# a bigger memory footprint. +# By default, a worker will span a number of process equal to your number +# of CPUs. You can adjust this, by explicitly setting the --concurrency +# flag: +# celery -A funkwhale_api.taskapp worker -l INFO --concurrency=4 ExecStart=/srv/funkwhale/virtualenv/bin/celery -A funkwhale_api.taskapp worker -l INFO [Install] 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/docs/installation/index.rst b/docs/installation/index.rst index 2e62c71ec5b547da9a9fcd4db7a4566ff9814b57..776c22424f15929324520e1d57076d4bd2a5656c 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -12,9 +12,48 @@ The project relies on the following components and services to work: - A celery worker to run asynchronouse tasks (such as music import) - A celery scheduler to run recurrent tasks + +Hardware requirements +--------------------- + +Funkwhale is not especially CPU hungry, unless you're relying heavily +on the transcoding feature (which is basic and unoptimized at the moment). + +On a dockerized instance with 2 CPUs and a few active users, the memory footprint is around ~500Mb:: + + CONTAINER MEM USAGE + funkwhale_api_1 202.1 MiB + funkwhale_celerybeat_1 96.52 MiB + funkwhale_celeryworker_1 168.7 MiB + funkwhale_postgres_1 22.73 MiB + funkwhale_redis_1 1.496 MiB + +Thus, Funkwhale should run fine on commodity hardware, small hosting boxes and +Raspberry Pi. We lack real-world exemples of such deployments, so don't hesitate +do give us your feedback (either positive or negative). + +Software requirements +--------------------- + +Software requirements will vary depending of your installation method. For +Docker-based installations, the only requirement will be an Nginx reverse-proxy +that will expose your instance to the outside world. + +If you plan to install your Funkwhale instance without Docker, most of the +dependencies should be available in your distribution's repositories. + +.. note:: + + Funkwhale works only with Pyhon >= 3.5, as we need support for async/await. + Older versions of Python are not supported. + + Available installation methods ------------------------------- +Docker is the recommended and easiest way to setup your Funkwhale instance. +We also maintain an installation guide for Debian 9. + .. toctree:: :maxdepth: 1 @@ -67,3 +106,24 @@ Then, download our sample virtualhost file and proxy conf: curl -L -o /etc/nginx/sites-enabled/funkwhale.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/nginx.conf" Ensure static assets and proxy pass match your configuration, and check the configuration is valid with ``nginx -t``. If everything is fine, you can restart your nginx server with ``service nginx restart``. + +About internal locations +~~~~~~~~~~~~~~~~~~~~~~~~ + +Music (and other static) files are never served by the app itself, but by the reverse +proxy. This is needed because a webserver is way more efficient at serving +files than a Python process. + +However, we do want to ensure users have the right to access music files, and +it can't be done at the proxy's level. To tackle this issue, `we use +nginx's internal directive <http://nginx.org/en/docs/http/ngx_http_core_module.html#internal>`_. + +When the API receives a request on its music serving endpoint, it will check +that the user making the request can access the file. Then, it will return an empty +response with a ``X-Accel-Redirect`` header. This header will contain the path +to the file to serve to the user, and will be picked by nginx, but never sent +back to the client. + +Using this technique, we can ensure music files are covered by the authentication +and permission policy of your instance, while keeping as much as performance +as possible. diff --git a/front/src/components/About.vue b/front/src/components/About.vue index 09a5ee24c523f8f8f5db77b47b7c6694ae74387b..524191250cf920fdbe4991381f827c1e50dd5ecd 100644 --- a/front/src/components/About.vue +++ b/front/src/components/About.vue @@ -3,15 +3,16 @@ <div class="ui vertical center aligned stripe segment"> <div class="ui text container"> <h1 class="ui huge header"> - <template v-if="instance.name.value">About {{ instance.name.value }}</template> - <template v-else="instance.name.value">About this instance</template> + <template v-if="instance.name.value">{{ $t('About {%instance%}', { instance: instance.name.value }) }}</template> + <template v-else="instance.name.value">{{ $t('About this instance') }}</template> </h1> <stats></stats> </div> </div> <div class="ui vertical stripe segment"> <p v-if="!instance.short_description.value && !instance.long_description.value"> - Unfortunately, owners of this instance did not yet take the time to complete this page.</p> + {{ $t('Unfortunately, owners of this instance did not yet take the time to complete this page.') }} + </p> <div v-if="instance.short_description.value" class="ui middle aligned stackable text container"> diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index ce1307ff0c8a84b934f027c7a27509644edc32a5..03f4513e6cb645ab17dc79c75dcee32c202ef0b8 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -3,15 +3,15 @@ <div class="ui vertical center aligned stripe segment"> <div class="ui text container"> <h1 class="ui huge header"> - Welcome on Funkwhale + {{ $t('Welcome on Funkwhale') }} </h1> - <p>We think listening music should be simple.</p> + <p>{{ $t('We think listening music should be simple.') }}</p> <router-link class="ui icon button" to="/about"> <i class="info icon"></i> - Learn more about this instance + {{ $t('Learn more about this instance') }} </router-link> <router-link class="ui icon teal button" to="/library"> - Get me to the library + {{ $t('Get me to the library') }} <i class="right arrow icon"></i> </router-link> </div> @@ -22,9 +22,9 @@ <div class="row"> <div class="eight wide left floated column"> <h2 class="ui header"> - Why funkwhale? + {{ $t('Why funkwhale?') }} </h2> - <p>That's simple: we loved Grooveshark and we want to build something even better.</p> + <p>{{ $t('That\'s simple: we loved Grooveshark and we want to build something even better.') }}</p> </div> <div class="four wide left floated column"> <img class="ui medium image" src="../assets/logo/logo.png" /> @@ -35,26 +35,26 @@ <div class="ui middle aligned stackable text container"> <div class="ui hidden divider"></div> <h2 class="ui header"> - Unlimited music + {{ $t('Unlimited music') }} </h2> - <p>Funkwhale is designed to make it easy to listen to music you like, or to discover new artists.</p> + <p>{{ $t('Funkwhale is designed to make it easy to listen to music you like, or to discover new artists.') }}</p> <div class="ui list"> <div class="item"> <i class="sound icon"></i> <div class="content"> - Click once, listen for hours using built-in radios + {{ $t('Click once, listen for hours using built-in radios') }} </div> </div> <div class="item"> <i class="heart icon"></i> <div class="content"> - Keep a track of your favorite songs + {{ $t('Keep a track of your favorite songs') }} </div> </div> <div class="item"> <i class="list icon"></i> <div class="content"> - Playlists? We got them + {{ $t('Playlists? We got them') }} </div> </div> </div> @@ -62,26 +62,28 @@ <div class="ui middle aligned stackable text container"> <div class="ui hidden divider"></div> <h2 class="ui header"> - Clean library + {{ $t('Clean library') }} </h2> - <p>Funkwhale takes care of handling your music.</p> + <p>{{ $t('Funkwhale takes care of handling your music') }}.</p> <div class="ui list"> <div class="item"> <i class="download icon"></i> <div class="content"> - Import music from various platforms, such as YouTube or SoundCloud + {{ $t('Import music from various platforms, such as YouTube or SoundCloud') }} </div> </div> <div class="item"> <i class="tag icon"></i> <div class="content"> - Get quality metadata about your music thanks to <a href="https://musicbrainz.org" target="_blank">MusicBrainz</a> + <i18next path="Get quality metadata about your music thanks to {%0%}"> + <a href="https://musicbrainz.org" target="_blank">{{ $t('MusicBrainz') }}</a> + </i18next> </div> </div> <div class="item"> <i class="plus icon"></i> <div class="content"> - Covers, lyrics, our goal is to have them all ;) + {{ $t('Covers, lyrics, our goal is to have them all ;)') }} </div> </div> </div> @@ -89,20 +91,20 @@ <div class="ui middle aligned stackable text container"> <div class="ui hidden divider"></div> <h2 class="ui header"> - Easy to use + {{ $t('Easy to use') }} </h2> - <p>Funkwhale is dead simple to use.</p> + <p>{{ $t('Funkwhale is dead simple to use.') }}</p> <div class="ui list"> <div class="item"> <i class="book icon"></i> <div class="content"> - No add-ons, no plugins : you only need a web library + {{ $t('No add-ons, no plugins : you only need a web library') }} </div> </div> <div class="item"> <i class="wizard icon"></i> <div class="content"> - Access your music from a clean interface that focus on what really matters + {{ $t('Access your music from a clean interface that focus on what really matters') }} </div> </div> </div> @@ -110,26 +112,26 @@ <div class="ui middle aligned stackable text container"> <div class="ui hidden divider"></div> <h2 class="ui header"> - Your music, your way + {{ $t('Your music, your way') }} </h2> - <p>Funkwhale is free and gives you control on your music.</p> + <p>{{ $t('Funkwhale is free and gives you control on your music.') }}</p> <div class="ui list"> <div class="item"> <i class="smile icon"></i> <div class="content"> - The plaform is free and open-source, you can install it and modify it without worries + {{ $t('The plaform is free and open-source, you can install it and modify it without worries') }} </div> </div> <div class="item"> <i class="protect icon"></i> <div class="content"> - We do not track you or bother you with ads + {{ $t('We do not track you or bother you with ads') }} </div> </div> <div class="item"> <i class="users icon"></i> <div class="content"> - You can invite friends and family to your instance so they can enjoy your music + {{ $t('You can invite friends and family to your instance so they can enjoy your music') }} </div> </div> </div> diff --git a/front/src/components/PageNotFound.vue b/front/src/components/PageNotFound.vue index 25e6f86fd209a7cb6132c9c36812e918c1f116f9..b4d2250ca061db4b36c729b42dc22901d684cd7d 100644 --- a/front/src/components/PageNotFound.vue +++ b/front/src/components/PageNotFound.vue @@ -5,13 +5,13 @@ <h1 class="ui huge header"> <i class="warning icon"></i> <div class="content"> - <strike>Whale</strike> Page not found! + <strike>{{ $t('Whale') }}</strike> {{ $t('Page not found!') }} </div> </h1> - <p>We're sorry, the page you asked for does not exists.</p> - <p>Requested URL: <a :href="path">{{ path }}</a></p> + <p>{{ $t('We\'re sorry, the page you asked for does not exists.') }}</p> + <i18next path="Requested URL: {%0%}"><a :href="path">{{ path }}</a></i18next> <router-link class="ui icon button" to="/"> - Go to home page + {{ $t('Go to home page') }} <i class="right arrow icon"></i> </router-link> </div> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 96047ab9848621c55fe5b75971bfca47e6c9ba62..fb4074d80703ff8c13685eb7ff1298551e3ff250 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -18,12 +18,12 @@ <div class="ui compact fluid two item inverted menu"> <a class="active item" @click="selectedTab = 'library'" data-tab="library">Browse</a> <a class="item" @click="selectedTab = 'queue'" data-tab="queue"> - Queue + {{ $t('Queue') }} <template v-if="queue.tracks.length === 0"> - (empty) + {{ $t('(empty)') }} </template> <template v-else> - ({{ queue.currentIndex + 1}} of {{ queue.tracks.length }}) + {{ $t('({%index%} of {%length%})', { index: queue.currentIndex + 1, length: queue.tracks.length }) }} </template> </a> </div> @@ -31,37 +31,35 @@ <div class="tabs"> <div class="ui bottom attached active tab" data-tab="library"> <div class="ui inverted vertical fluid menu"> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i> Logged in as {{ $store.state.auth.username }}</router-link> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link> - <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link> - <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link> - <router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link> + <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</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" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</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" class="item"> - <i class="list icon"></i> Playlists + <i class="list icon"></i> {{ $t('Playlists') }} </a> <router-link v-if="$store.state.auth.authenticated" - class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link> + class="item" :to="{path: '/activity'}"><i class="bell icon"></i> {{ $t('Activity') }}</router-link> <router-link class="item" v-if="$store.state.auth.availablePermissions['federation.manage']" - :to="{path: '/manage/federation/libraries'}"><i class="sitemap icon"></i> Federation</router-link> + :to="{path: '/manage/federation/libraries'}"><i class="sitemap icon"></i> {{ $t('Federation') }}</router-link> </div> - - <player></player> </div> <div v-if="queue.previousQueue " class="ui black icon message"> <i class="history icon"></i> <div class="content"> <div class="header"> - Do you want to restore your previous queue? + {{ $t('Do you want to restore your previous queue?') }} </div> - <p>{{ queue.previousQueue.tracks.length }} tracks</p> + <p>{{ $t('{%count%} tracks', { count: queue.previousQueue.tracks.length }) }}</p> <div class="ui two buttons"> - <div @click="queue.restore()" class="ui basic inverted green button">Yes</div> - <div @click="queue.removePrevious()" class="ui basic inverted red button">No</div> + <div @click="queue.restore()" class="ui basic inverted green button">{{ $t('Yes') }}</div> + <div @click="queue.removePrevious()" class="ui basic inverted red button">{{ $t('No') }}</div> </div> </div> </div> @@ -90,17 +88,17 @@ </draggable> </table> <div v-if="$store.state.radios.running" class="ui black message"> - <div class="content"> <div class="header"> - <i class="feed icon"></i> You have a radio playing + <i class="feed icon"></i> {{ $t('You have a radio playing') }} </div> - <p>New tracks will be appended here automatically.</p> - <div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">Stop radio</div> + <p>{{ $t('New tracks will be appended here automatically.') }}</p> + <div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">{{ $t('Stop radio') }}</div> </div> </div> </div> </div> + <player @next="scrollToCurrent" @previous="scrollToCurrent"></player> </div> </template> @@ -143,8 +141,9 @@ export default { ...mapActions({ cleanTrack: 'queue/cleanTrack' }), - reorder: function (oldValue, newValue) { - this.$store.commit('queue/reorder', {oldValue, newValue}) + reorder: function (event) { + this.$store.commit('queue/reorder', { + oldIndex: event.oldIndex, newIndex: event.newIndex}) }, scrollToCurrent () { let current = $(this.$el).find('[data-tab="queue"] .active')[0] @@ -159,7 +158,6 @@ export default { // for half the height of the containers display area var scrollBack = (container.scrollHeight - container.scrollTop <= container.clientHeight) ? 0 : container.clientHeight / 2 container.scrollTop = container.scrollTop - scrollBack - console.log(container.scrollHeight - container.scrollTop, container.clientHeight) } }, watch: { @@ -239,9 +237,6 @@ $sidebar-color: #3D3E3F; flex-direction: column; overflow-y: auto; justify-content: space-between; - @include media(">tablet") { - height: 0px; - } @include media("<desktop") { max-height: 500px; } diff --git a/front/src/components/activity/Like.vue b/front/src/components/activity/Like.vue index deda121cc9f8def680eeebd08159fc92c5ee97db..5396accc233033222eca551364cc4da740f59a47 100644 --- a/front/src/components/activity/Like.vue +++ b/front/src/components/activity/Like.vue @@ -5,10 +5,10 @@ </div> <div class="content"> <div class="summary"> - <i18next path="{%0%} favorited a track {%1%}"> - <slot name="user"></slot> - <slot name="date"></slot> + <i18next path="{%0%} favorited a track"> + <username class="user" :username="event.actor.local_id" /> </i18next> + <human-date class="date" :date="event.published" /> </div> <div class="extra text"> <router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link> diff --git a/front/src/components/activity/Listen.vue b/front/src/components/activity/Listen.vue index d207c280deef14f44cada9342a2732eabfdc1c48..bfa3ca16450a69adfa086e1189ab0446e81e0733 100644 --- a/front/src/components/activity/Listen.vue +++ b/front/src/components/activity/Listen.vue @@ -5,16 +5,16 @@ </div> <div class="content"> <div class="summary"> - <i18next path="{%0%} listened to a track {%1%}"> - <slot name="user"></slot> - <slot name="date"></slot> + <i18next path="{%0%} listened to a track"> + <username class="user" :username="event.actor.local_id" /> </i18next> + <human-date class="date" :date="event.published" /> + </div> <div class="extra text"> <router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link> <i18next path="from album {%0%}, by {%1%}" v-if="event.object.album"> - {{ event.object.album }} - <em>{{ event.object.artist }}</em> + {{ event.object.album }}<em>{{ event.object.artist }}</em> </i18next> <i18next path=", by {%0%}" v-else> <em>{{ event.object.artist }}</em> diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index ad90a599528a09cfd287389ddfe22a7585f9fa69..c475ec684a008ec46392361523eefc77ab38b0e6 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -4,7 +4,7 @@ <audio-track ref="currentAudio" v-if="renderAudio && currentTrack" - :key="(currentIndex, currentTrack.id)" + :key="currentTrack.id" :is-current="true" :start-time="$store.state.player.currentTime" :autoplay="$store.state.player.playing" @@ -173,11 +173,21 @@ export default { ...mapActions({ togglePlay: 'player/togglePlay', clean: 'queue/clean', - next: 'queue/next', - previous: 'queue/previous', shuffle: 'queue/shuffle', updateProgress: 'player/updateProgress' }), + next () { + let self = this + this.$store.dispatch('queue/next').then(() => { + self.$emit('next') + }) + }, + previous () { + let self = this + this.$store.dispatch('queue/previous').then(() => { + self.$emit('previous') + }) + }, touchProgress (e) { let time let target = this.$refs.progress diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index 68dd34459870f432d7ca5c9e3b35a6d7966b225d..08a055f5ca97186d51130e419176338473f9390e 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -73,7 +73,10 @@ export default { }, methods: { errored: function () { - this.$store.dispatch('player/trackErrored') + let self = this + setTimeout( + () => { self.$store.dispatch('player/trackErrored') } + , 1000) }, sourceErrored: function () { this.sourceErrors += 1 @@ -83,9 +86,15 @@ export default { } }, updateDuration: function (e) { + if (!this.$refs.audio) { + return + } this.$store.commit('player/duration', this.$refs.audio.duration) }, loaded: function () { + if (!this.$refs.audio) { + return + } this.$refs.audio.volume = this.volume this.$store.commit('player/resetErrorCount') if (this.isCurrent) { diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index 5c17ac6af3d338fbdafff69511d7cc35c42877de..e16cb6587431d0d7494cae1ef0e92bf5c96fe189 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -11,10 +11,7 @@ <div class="content"> {{ artist.name }} <div class="sub header"> - <i18next path="{%0%} tracks in {%1%} albums"> - {{ totalTracks }} - {{ albums.length }} - </i18next> + {{ $t('{% track_count %} tracks in {% album_count %} albums', {track_count: totalTracks, album_count: albums.length})}} </div> </div> </h2> diff --git a/front/src/components/library/import/BatchDetail.vue b/front/src/components/library/import/BatchDetail.vue index c7894fcc0249c4f27f58e6c6f29327b52f3929f5..b73c8cf8257599e87cc21aea05ce669f58aaac45 100644 --- a/front/src/components/library/import/BatchDetail.vue +++ b/front/src/components/library/import/BatchDetail.vue @@ -4,31 +4,80 @@ <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <div v-if="batch" class="ui vertical stripe segment"> - <div :class=" - ['ui', - {'active': batch.status === 'pending'}, - {'warning': batch.status === 'pending'}, - {'error': batch.status === 'errored'}, - {'success': batch.status === 'finished'}, - 'progress']"> - <div class="bar" :style="progressBarStyle"> - <div class="progress"></div> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <strong>{{ $t('Import batch') }}</strong> + </td> + <td> + #{{ batch.id }} + </td> + </tr> + <tr> + <td> + <strong>{{ $t('Launch date') }}</strong> + </td> + <td> + <human-date :date="batch.creation_date"></human-date> + </td> + </tr> + <tr v-if="batch.user"> + <td> + <strong>{{ $t('Submitted by') }}</strong> + </td> + <td> + <username :username="batch.user.username" /> + </td> + </tr> + <tr v-if="stats"> + <td><strong>{{ $t('Pending') }}</strong></td> + <td>{{ stats.pending }}</td> + </tr> + <tr v-if="stats"> + <td><strong>{{ $t('Skipped') }}</strong></td> + <td>{{ stats.skipped }}</td> + </tr> + <tr v-if="stats"> + <td><strong>{{ $t('Errored') }}</strong></td> + <td>{{ stats.errored }}</td> + </tr> + <tr v-if="stats"> + <td><strong>{{ $t('Finished') }}</strong></td> + <td>{{ stats.finished }}/{{ stats.count}}</td> + </tr> + </tbody> + </table> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="jobFilters.search" placeholder="Search by source..." /> + </div> + <div class="ui field"> + <label>{{ $t('Status') }}</label> + <select class="ui dropdown" v-model="jobFilters.status"> + <option :value="null">{{ $t('Any') }}</option> + <option :value="'pending'">{{ $t('Pending') }}</option> + <option :value="'errored'">{{ $t('Errored') }}</option> + <option :value="'finished'">{{ $t('Success') }}</option> + <option :value="'skipped'">{{ $t('Skipped') }}</option> + </select> + </div> </div> - <div v-if="batch.status === 'pending'" class="label">Importing {{ batch.jobs.length }} tracks...</div> - <div v-if="batch.status === 'finished'" class="label">Imported {{ batch.jobs.length }} tracks!</div> </div> - <table class="ui unstackable table"> + <table v-if="jobResult" class="ui unstackable table"> <thead> <tr> - <i18next tag="th" path="Job ID"/> - <i18next tag="th" path="Recording MusicBrainz ID"/> - <i18next tag="th" path="Source"/> - <i18next tag="th" path="Status"/> - <i18next tag="th" path="Track"/> + <th>{{ $t('Job ID') }}</th> + <th>{{ $t('Recording MusicBrainz ID') }}</th> + <th>{{ $t('Source') }}</th> + <th>{{ $t('Status') }}</th> + <th>{{ $t('Track') }}</th> </tr> </thead> <tbody> - <tr v-for="job in batch.jobs"> + <tr v-for="job in jobResult.results"> <td>{{ job.id }}</th> <td> <a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a> @@ -45,29 +94,64 @@ </td> </tr> </tbody> + <tfoot class="full-width"> + <tr> + <th> + <pagination + v-if="jobResult && jobResult.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="jobFilters.page" + :paginate-by="jobFilters.paginateBy" + :total="jobResult.count" + ></pagination> + </th> + <th v-if="jobResult && jobResult.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((jobFilters.page-1) * jobFilters.paginateBy) + 1 , end: ((jobFilters.page-1) * jobFilters.paginateBy) + jobResult.results.length, total: jobResult.count})}} + <th> + <th></th> + <th></th> + <th></th> + </tr> + </tfoot> </table> - </div> </div> </template> <script> +import _ from 'lodash' import axios from 'axios' import logger from '@/logging' - -const FETCH_URL = 'import-batches/' +import Pagination from '@/components/Pagination' export default { props: ['id'], + components: { + Pagination + }, data () { return { isLoading: true, batch: null, - timeout: null + stats: null, + jobResult: null, + timeout: null, + jobFilters: { + status: null, + source: null, + search: '', + paginateBy: 25, + page: 1 + } } }, created () { - this.fetchData() + let self = this + this.fetchData().then(() => { + self.fetchJobs() + self.fetchStats() + }) }, destroyed () { if (this.timeout) { @@ -78,9 +162,9 @@ export default { fetchData () { var self = this this.isLoading = true - let url = FETCH_URL + this.id + '/' + let url = 'import-batches/' + this.id + '/' logger.default.debug('Fetching batch "' + this.id + '"') - axios.get(url).then((response) => { + return axios.get(url).then((response) => { self.batch = response.data self.isLoading = false if (self.batch.status === 'pending') { @@ -90,21 +174,58 @@ export default { ) } }) - } - }, - computed: { - progress () { - return this.batch.jobs.filter(j => { - return j.status !== 'pending' - }).length * 100 / this.batch.jobs.length }, - progressBarStyle () { - return 'width: ' + parseInt(this.progress) + '%' + fetchStats () { + var self = this + let url = 'import-jobs/stats/' + axios.get(url, {params: {batch: self.id}}).then((response) => { + let old = self.stats + self.stats = response.data + self.isLoading = false + if (!_.isEqual(old, self.stats)) { + self.fetchJobs() + self.fetchData() + } + if (self.batch.status === 'pending') { + self.timeout = setTimeout( + self.fetchStats, + 5000 + ) + } + }) + }, + fetchJobs () { + let params = { + batch: this.id, + page_size: this.jobFilters.paginateBy, + page: this.jobFilters.page, + q: this.jobFilters.search + } + if (this.jobFilters.status) { + params.status = this.jobFilters.status + } + if (this.jobFilters.source) { + params.source = this.jobFilters.source + } + let self = this + axios.get('import-jobs/', {params}).then((response) => { + self.jobResult = response.data + }) + }, + selectPage: function (page) { + this.jobFilters.page = page } + }, watch: { id () { this.fetchData() + }, + jobFilters: { + handler () { + this.fetchJobs() + }, + deep: true } } } diff --git a/front/src/components/library/import/BatchList.vue b/front/src/components/library/import/BatchList.vue index 324c3990a1494892435162bb52aa78fb516478c1..bf5a0ca47599e79b56559563aca8291fb3aff0e4 100644 --- a/front/src/components/library/import/BatchList.vue +++ b/front/src/components/library/import/BatchList.vue @@ -2,76 +2,144 @@ <div v-title="'Import Batches'"> <div class="ui vertical stripe segment"> <div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - <button - class="ui left floated labeled icon button" - @click="fetchData(previousLink)" - :disabled="!previousLink"><i class="left arrow icon"></i><i18next path="Previous"/></button> - <button - class="ui right floated right labeled icon button" - @click="fetchData(nextLink)" - :disabled="!nextLink"><i18next path="Next"/><i class="right arrow icon"></i></button> - <div class="ui hidden clearing divider"></div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="filters.search" placeholder="Search by submitter, source..." /> + </div> + <div class="ui field"> + <label>{{ $t('Status') }}</label> + <select class="ui dropdown" v-model="filters.status"> + <option :value="null">{{ $t('Any') }}</option> + <option :value="'pending'">{{ $t('Pending') }}</option> + <option :value="'errored'">{{ $t('Errored') }}</option> + <option :value="'finished'">{{ $t('Success') }}</option> + </select> + </div> + <div class="ui field"> + <label>{{ $t('Import source') }}</label> + <select class="ui dropdown" v-model="filters.source"> + <option :value="null">{{ $t('Any') }}</option> + <option :value="'shell'">{{ $t('CLI') }}</option> + <option :value="'api'">{{ $t('API') }}</option> + <option :value="'federation'">{{ $t('Federation') }}</option> + </select> + </div> + </div> + </div> <div class="ui hidden clearing divider"></div> - <table v-if="results.length > 0" class="ui unstackable table"> + <table v-if="result && result.results.length > 0" class="ui unstackable table"> <thead> <tr> - <i18next tag="th" path="ID"/> - <i18next tag="th" path="Launch date"/> - <i18next tag="th" path="Jobs"/> - <i18next tag="th" path="Status"/> + <th>{{ $t('ID') }}</th> + <th>{{ $t('Launch date') }}</th> + <th>{{ $t('Jobs') }}</th> + <th>{{ $t('Status') }}</th> + <th>{{ $t('Source') }}</th> + <th>{{ $t('Submitted by') }}</th> </tr> </thead> <tbody> - <tr v-for="result in results"> - <td>{{ result.id }}</th> + <tr v-for="obj in result.results"> + <td>{{ obj.id }}</th> <td> - <router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}"> - {{ result.creation_date }} + <router-link :to="{name: 'library.import.batches.detail', params: {id: obj.id }}"> + <human-date :date="obj.creation_date"></human-date> </router-link> </td> - <td>{{ result.jobs.length }}</td> + <td>{{ obj.job_count }}</td> <td> <span - :class="['ui', {'yellow': result.status === 'pending'}, {'red': result.status === 'errored'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span> - </td> - </tr> - </tbody> - </table> - </div> + :class="['ui', {'yellow': obj.status === 'pending'}, {'red': obj.status === 'errored'}, {'green': obj.status === 'finished'}, 'label']">{{ obj.status }} + </span> + </td> + <td>{{ obj.source }}</td> + <td><template v-if="obj.submitted_by">{{ obj.submitted_by.username }}</template></td> + </tr> + </tbody> + <tfoot class="full-width"> + <tr> + <th> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="filters.page" + :paginate-by="filters.paginateBy" + :total="result.count" + ></pagination> + </th> + <th v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((filters.page-1) * filters.paginateBy) + 1 , end: ((filters.page-1) * filters.paginateBy) + result.results.length, total: result.count})}} + <th> + <th></th> + <th></th> + <th></th> + </tr> + </tfoot> + </table> + </div> </div> </template> <script> import axios from 'axios' import logger from '@/logging' - -const BATCHES_URL = 'import-batches/' +import Pagination from '@/components/Pagination' export default { - components: {}, + components: { + Pagination + }, data () { return { - results: [], + result: null, isLoading: false, - nextLink: null, - previousLink: null + filters: { + status: null, + source: null, + search: '', + paginateBy: 25, + page: 1 + } } }, created () { - this.fetchData(BATCHES_URL) + this.fetchData() }, methods: { - fetchData (url) { + fetchData () { + let params = { + page_size: this.filters.paginateBy, + page: this.filters.page, + q: this.filters.search + } + if (this.filters.status) { + params.status = this.filters.status + } + if (this.filters.source) { + params.source = this.filters.source + } var self = this this.isLoading = true logger.default.time('Loading import batches') - axios.get(url, {}).then((response) => { - self.results = response.data.results - self.nextLink = response.data.next - self.previousLink = response.data.previous + axios.get('import-batches/', {params}).then((response) => { + self.result = response.data logger.default.timeEnd('Loading import batches') self.isLoading = false }) + }, + selectPage: function (page) { + this.filters.page = page + } + }, + watch: { + filters: { + handler () { + this.fetchData() + }, + deep: true } } } diff --git a/front/src/components/metadata/ArtistCard.vue b/front/src/components/metadata/ArtistCard.vue index 3a50a315522aed317437e203080f4a7061e4fc3c..c88438c0c0917e0394450affa04e1c1e5ec1958f 100644 --- a/front/src/components/metadata/ArtistCard.vue +++ b/front/src/components/metadata/ArtistCard.vue @@ -16,7 +16,7 @@ {{ group['first-release-date'] }} </td> <td colspan="3"> - <a :href="getMusicbrainzUrl('release-group', group.id)" class="discrete link" target="_blank" title="View on MusicBrainz"> + <a :href="getMusicbrainzUrl('release-group', group.id)" class="discrete link" target="_blank" :title="$t('View on MusicBrainz')"> {{ group.title }} </a> </td> diff --git a/front/src/components/metadata/ReleaseCard.vue b/front/src/components/metadata/ReleaseCard.vue index 201c3ab0c527440acb56f4320ff2e505a6f538c3..68bcd1284c06e84d0152d6e38b4803c32368236b 100644 --- a/front/src/components/metadata/ReleaseCard.vue +++ b/front/src/components/metadata/ReleaseCard.vue @@ -19,7 +19,7 @@ {{ track.position }} </td> <td colspan="3"> - <a :href="getMusicbrainzUrl('recording', track.id)" class="discrete link" target="_blank" title="View on MusicBrainz"> + <a :href="getMusicbrainzUrl('recording', track.id)" class="discrete link" target="_blank" :title="$t('View on MusicBrainz')"> {{ track.recording.title }} </a> </td> diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue index f2dea6cab9dfa45c0edc8fb3ff995961ca1b3dea..305aa7a3d6b3aa7e695c9613f566946c3e7be805 100644 --- a/front/src/components/metadata/Search.vue +++ b/front/src/components/metadata/Search.vue @@ -12,7 +12,7 @@ </div> <div class="ui fluid search"> <div class="ui icon input"> - <input class="prompt" placeholder="Enter your search query..." type="text"> + <input class="prompt" :placeholder="$t('Enter your search query...')" type="text"> <i class="search icon"></i> </div> <div class="results"></div> @@ -32,21 +32,7 @@ export default { data: function () { return { currentType: this.mbType || 'artist', - currentId: this.mbId || '', - types: [ - { - value: 'artist', - label: 'Artist' - }, - { - value: 'release', - label: 'Album' - }, - { - value: 'recording', - label: 'Track' - } - ] + currentId: this.mbId || '' } }, @@ -132,6 +118,22 @@ export default { }, searchUrl: function () { return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}' + }, + types: function () { + return [ + { + value: 'artist', + label: this.$t('Artist') + }, + { + value: 'release', + label: this.$t('Album') + }, + { + value: 'recording', + label: this.$t('Track') + } + ] } }, watch: { diff --git a/front/src/components/playlists/Card.vue b/front/src/components/playlists/Card.vue index 6dd1b0a0ce477713af1e9dd9e7b33cff40906730..670b43194b5d78aaf485d37f785107d78a5b13d9 100644 --- a/front/src/components/playlists/Card.vue +++ b/front/src/components/playlists/Card.vue @@ -10,13 +10,16 @@ <i class="user icon"></i> {{ playlist.user.username }} </div> <div class="meta"> - <i class="clock icon"></i> Updated <human-date :date="playlist.modification_date"></human-date> + <i class="clock icon"></i> + <i18next path="Updated {%date%}"> + <human-date :date="playlist.modification_date" /> + </i18next> </div> </div> <div class="extra content"> <span> <i class="sound icon"></i> - {{ playlist.tracks_count }} tracks + {{ $t('{%count%} tracks', { count: playlist.tracks_count }) }} </span> <play-button class="mini basic orange right floated" :playlist="playlist">Play all</play-button> </div> diff --git a/front/src/components/playlists/Editor.vue b/front/src/components/playlists/Editor.vue index c668857ea1f88557b22d77ecb81acdfd5fe52162..c036737ce032461e8b02cd00d12593e27c960504 100644 --- a/front/src/components/playlists/Editor.vue +++ b/front/src/components/playlists/Editor.vue @@ -2,16 +2,16 @@ <div class="ui text container"> <playlist-form @updated="$emit('playlist-updated', $event)" :title="false" :playlist="playlist"></playlist-form> <h3 class="ui top attached header"> - Playlist editor + {{ $t('Playlist editor') }} </h3> <div class="ui attached segment"> <template v-if="status === 'loading'"> <div class="ui active tiny inline loader"></div> - Syncing changes to server... + {{ $t('Syncing changes to server...') }} </template> <template v-else-if="status === 'errored'"> <i class="red close icon"></i> - An error occured while saving your changes + {{ $t('An error occured while saving your changes') }} <div v-if="errors.length > 0" class="ui negative message"> <ul class="list"> <li v-for="error in errors">{{ error }}</li> @@ -19,7 +19,7 @@ </div> </template> <template v-else-if="status === 'saved'"> - <i class="green check icon"></i> Changes synced with server + <i class="green check icon"></i> {{ $t('Changes synced with server') }} </template> </div> <div class="ui bottom attached segment"> @@ -28,13 +28,15 @@ :disabled="queueTracks.length === 0" :class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']" title="Copy tracks from current queue to playlist"> - <i class="plus icon"></i> Insert from queue ({{ queueTracks.length }} tracks)</div> + <i class="plus icon"></i> + {{ $t('Insert from queue ({%count%} tracks)', { count: queueTracks.length }) }} + </div> <dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist"> - <i class="eraser icon"></i> Clear playlist - <p slot="modal-header">Do you want to clear the playlist "{{ playlist.name }}"?</p> - <p slot="modal-content">This will remove all tracks from this playlist and cannot be undone.</p> - <p slot="modal-confirm">Clear playlist</p> + <i class="eraser icon"></i> {{ $t('Clear playlist') }} + <p slot="modal-header">{{ $t('Do you want to clear the playlist "{%name%}"?', { name: playlist.name }) }}</p> + <p slot="modal-content">{{ $t('This will remove all tracks from this playlist and cannot be undone.') }}</p> + <p slot="modal-confirm">{{ $t('Clear playlist') }}</p> </dangerous-button> <div class="ui hidden divider"></div> <template v-if="plts.length > 0"> diff --git a/front/src/components/playlists/Form.vue b/front/src/components/playlists/Form.vue index 634e310bcdc2bb57484a8e048f65cfd8834da0d0..d19cd687668dca71fae2fabd8926426ae24986b4 100644 --- a/front/src/components/playlists/Form.vue +++ b/front/src/components/playlists/Form.vue @@ -1,29 +1,29 @@ <template> <form class="ui form" @submit.prevent="submit()"> - <h4 v-if="title" class="ui header">Create a new playlist</h4> + <h4 v-if="title" class="ui header">{{ $t('Create a new playlist') }}</h4> <div v-if="success" class="ui positive message"> <div class="header"> <template v-if="playlist"> - Playlist updated + {{ $t('Playlist updated') }} </template> <template v-else> - Playlist created + {{ $t('Playlist created') }} </template> </div> </div> <div v-if="errors.length > 0" class="ui negative message"> - <div class="header">We cannot create the playlist</div> + <div class="header">{{ $t('We cannot create the playlist') }}</div> <ul class="list"> <li v-for="error in errors">{{ error }}</li> </ul> </div> <div class="three fields"> <div class="field"> - <label>Playlist name</label> + <label>{{ $t('Playlist name') }}</label> <input v-model="name" required type="text" placeholder="My awesome playlist" /> </div> <div class="field"> - <label>Playlist visibility</label> + <label>{{ $t('Playlist visibility') }}</label> <select class="ui dropdown" v-model="privacyLevel"> <option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option> </select> @@ -31,8 +31,8 @@ <div class="field"> <label> </label> <button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit"> - <template v-if="playlist">Update playlist</template> - <template v-else>Create playlist</template> + <template v-if="playlist">{{ $t('Update playlist') }}</template> + <template v-else>{{ $t('Create playlist') }}</template> </button> </div> </div> @@ -57,30 +57,34 @@ export default { let d = { errors: [], success: false, - isLoading: false, - privacyLevelChoices: [ + isLoading: false + } + if (this.playlist) { + d.name = this.playlist.name + d.privacyLevel = this.playlist.privacy_level + } else { + d.privacyLevel = this.$store.state.auth.profile.privacy_level + d.name = '' + } + return d + }, + computed: { + privacyLevelChoices: function () { + return [ { value: 'me', - label: 'Nobody except me' + label: this.$t('Nobody except me') }, { value: 'instance', - label: 'Everyone on this instance' + label: this.$t('Everyone on this instance') }, { value: 'everyone', - label: 'Everyone' + label: this.$t('Everyone') } ] } - if (this.playlist) { - d.name = this.playlist.name - d.privacyLevel = this.playlist.privacy_level - } else { - d.privacyLevel = this.$store.state.auth.profile.privacy_level - d.name = '' - } - return d }, methods: { submit () { diff --git a/front/src/components/playlists/PlaylistModal.vue b/front/src/components/playlists/PlaylistModal.vue index 5fdf585dfbd7ce44ee9fcb4a3a9f6610e9d00d6c..404948dc0793cfce4a9515bfde6b76c95afa17a9 100644 --- a/front/src/components/playlists/PlaylistModal.vue +++ b/front/src/components/playlists/PlaylistModal.vue @@ -1,14 +1,14 @@ <template> <modal @update:show="update" :show="$store.state.playlists.showModal"> <div class="header"> - Manage playlists + {{ $t('Manage playlists') }} </div> <div class="scrolling content"> <div class="description"> <template v-if="track"> - <h4 class="ui header">Current track</h4> + <h4 class="ui header">{{ $t('Current track') }}</h4> <div> - "{{ track.title }}" by {{ track.artist.name }} + {{ $t('"{%title%}" by {%artist%}', { title: track.title, artist: track.artist.name }) }} </div> <div class="ui divider"></div> </template> @@ -16,20 +16,20 @@ <playlist-form></playlist-form> <div class="ui divider"></div> <div v-if="errors.length > 0" class="ui negative message"> - <div class="header">We cannot add the track to a playlist</div> + <div class="header">{{ $t('We cannot add the track to a playlist') }}</div> <ul class="list"> <li v-for="error in errors">{{ error }}</li> </ul> </div> </div> - <h4 class="ui header">Available playlists</h4> + <h4 class="ui header">{{ $t('Available playlists') }}</h4> <table class="ui unstackable very basic table"> <thead> <tr> <th></th> - <th>Name</th> - <th class="sorted descending">Last modification</th> - <th>Tracks</th> + <th>{{ $t('Name') }}</th> + <th class="sorted descending">{{ $t('Last modification') }}</th> + <th>{{ $t('Tracks') }}</th> <th></th> </tr> </thead> @@ -48,9 +48,9 @@ <div v-if="track" class="ui green icon basic small right floated button" - title="Add to this playlist" + :title="$t('Add to this playlist')" @click="addToPlaylist(playlist.id)"> - <i class="plus icon"></i> Add track + <i class="plus icon"></i> {{ $t('Add track') }} </div> </td> </tr> @@ -59,7 +59,7 @@ </div> </div> <div class="actions"> - <div class="ui cancel button">Cancel</div> + <div class="ui cancel button">{{ $t('Cancel') }}</div> </div> </modal> </template> diff --git a/front/src/components/playlists/TrackPlaylistIcon.vue b/front/src/components/playlists/TrackPlaylistIcon.vue index bba4c515b0c90644182016f786c06e60b233b552..cd74b4d275996380122b2a134e7519f2e9f3a343 100644 --- a/front/src/components/playlists/TrackPlaylistIcon.vue +++ b/front/src/components/playlists/TrackPlaylistIcon.vue @@ -4,13 +4,13 @@ v-if="button" :class="['ui', 'button']"> <i class="list icon"></i> - Add to playlist... + {{ $t('Add to playlist...') }} </button> <i v-else @click="$store.commit('playlists/chooseTrack', track)" :class="['playlist-icon', 'list', 'link', 'icon']" - title="Add to playlist..."> + :title="$t('Add to playlist...')"> </i> </template> diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue index 0869313a875eb153812a5b9fce0cf1ea1a932d81..abdc660fcedf463ad4abfb4abbd9fff90317bb80 100644 --- a/front/src/components/radios/Button.vue +++ b/front/src/components/radios/Button.vue @@ -1,8 +1,8 @@ <template> <button @click="toggleRadio" :class="['ui', 'blue', {'inverted': running}, 'button']"> <i class="ui feed icon"></i> - <template v-if="running">Stop</template> - <template v-else>Start</template> + <template v-if="running">{{ $t('Stop') }}</template> + <template v-else>{{ $t('Start') }}</template> radio </button> </template> diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue index 17de3c85fe3c67d5a124d046aa9e5fa233503396..62de6ec65dcd0c9c0a8aa2bd70f3a31392b21e15 100644 --- a/front/src/components/radios/Card.vue +++ b/front/src/components/radios/Card.vue @@ -15,7 +15,7 @@ class="ui basic yellow button" v-if="$store.state.auth.authenticated && type === 'custom' && customRadio.user === $store.state.auth.profile.id" :to="{name: 'library.radios.edit', params: {id: customRadioId }}"> - Edit... + {{ $t('Edit...') }} </router-link> <radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId"></radio-button> </div> diff --git a/front/src/components/requests/Card.vue b/front/src/components/requests/Card.vue index 17fecde5294a1e26d78467d8efc780b1b023b19f..9d0bf0b771bdaa398c882e84be4f07afb642c6ce 100644 --- a/front/src/components/requests/Card.vue +++ b/front/src/components/requests/Card.vue @@ -23,7 +23,7 @@ <button @click="createImport" v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']" - class="ui mini basic green right floated button">Create import</button> + class="ui mini basic green right floated button">{{ $t('Create import') }}</button> </div> </div> diff --git a/front/src/components/requests/Form.vue b/front/src/components/requests/Form.vue index 68c725ba7adfe903c3ecdf971067b1abb2d5baef..b03e1545ba4d7861fc6b02c71e752f630127fb98 100644 --- a/front/src/components/requests/Form.vue +++ b/front/src/components/requests/Form.vue @@ -1,30 +1,30 @@ <template> <div> <form v-if="!over" class="ui form" @submit.prevent="submit"> - <p>Something's missing in the library? Let us know what you would like to listen!</p> + <p>{{ $t('Something\'s missing in the library? Let us know what you would like to listen!') }}</p> <div class="required field"> - <label>Artist name</label> + <label>{{ $t('Artist name') }}</label> <input v-model="currentArtistName" placeholder="The Beatles, Mickael Jackson…" required maxlength="200"> </div> <div class="field"> - <label>Albums</label> - <p>Leave this field empty if you're requesting the whole discography.</p> + <label>{{ $t('Albums') }}</label> + <p>{{ $t('Leave this field empty if you\'re requesting the whole discography.') }}</p> <input v-model="currentAlbums" placeholder="The White Album, Thriller…" maxlength="2000"> </div> <div class="field"> - <label>Comment</label> + <label>{{ $t('Comment') }}</label> <textarea v-model="currentComment" rows="3" placeholder="Use this comment box to add details to your request if needed" maxlength="2000"></textarea> </div> - <button class="ui submit button" type="submit">Submit</button> + <button class="ui submit button" type="submit">{{ $t('Submit') }}</button> </form> <div v-else class="ui success message"> <div class="header">Request submitted!</div> - <p>We've received your request, you'll get some groove soon ;)</p> - <button @click="reset" class="ui button">Submit another request</button> + <p>{{ $t('We\'ve received your request, you\'ll get some groove soon ;)') }}</p> + <button @click="reset" class="ui button">{{ $t('Submit another request') }}</button> </div> <div v-if="requests.length > 0"> <div class="ui divider"></div> - <h3 class="ui header">Pending requests</h3> + <h3 class="ui header">{{ $t('Pending requests') }}</h3> <div class="ui list"> <div v-for="request in requests" class="item"> <div class="content"> diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue index 5d4db243acce765d72e64c61e3771b54bf17e452..4464031c5e6918d0b9222922687fdc8e2fc59abc 100644 --- a/front/src/components/requests/RequestsList.vue +++ b/front/src/components/requests/RequestsList.vue @@ -1,15 +1,15 @@ <template> <div v-title="'Import Requests'"> <div class="ui vertical stripe segment"> - <h2 class="ui header">Music requests</h2> + <h2 class="ui header">{{ $t('Music requests') }}</h2> <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 artist name, a username..."/> </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] }} @@ -17,14 +17,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> </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> @@ -96,12 +96,7 @@ export default { query: this.defaultQuery, paginateBy: parseInt(this.defaultPaginateBy || 12), orderingDirection: defaultOrdering.direction, - ordering: defaultOrdering.field, - orderingOptions: [ - ['creation_date', 'Creation date'], - ['artist_name', 'Artist name'], - ['user__username', 'User'] - ] + ordering: defaultOrdering.field } }, created () { @@ -141,6 +136,15 @@ export default { this.page = page } }, + computed: { + orderingOptions: function () { + return [ + ['creation_date', this.$t('Creation date')], + ['artist_name', this.$t('Artist name')], + ['user__username', this.$t('User')] + ] + } + }, watch: { page () { this.updateQueryString() diff --git a/front/src/main.js b/front/src/main.js index 0c41294113929d4421a6ee36808be445de7ada5d..5481615f2006025cea7e009dc9ddd8a49b97623f 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -35,14 +35,14 @@ Vue.use(VueMasonryPlugin) Vue.use(VueLazyload) Vue.config.productionTip = false Vue.directive('title', { - inserted: (el, binding) => { console.log(binding.value); document.title = binding.value + ' - Funkwhale' }, + inserted: (el, binding) => { document.title = binding.value + ' - Funkwhale' }, updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' } }) axios.defaults.baseURL = config.API_URL axios.interceptors.request.use(function (config) { // Do something before request is sent - if (store.state.auth.authenticated) { + if (store.state.auth.token) { config.headers['Authorization'] = store.getters['auth/header'] } return config 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..23e074a80c36fb849eb306ea59da68756a05282b 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 }, @@ -86,14 +92,17 @@ export default { if (current) { dispatch('player/stop', null, {root: true}) } + commit('splice', {start: index, size: 1}) if (index < state.currentIndex) { - dispatch('currentIndex', state.currentIndex - 1) + commit('currentIndex', state.currentIndex - 1) } - commit('splice', {start: index, size: 1}) if (current) { // we play next track, which now have the same index dispatch('currentIndex', index) } + if (state.currentIndex + 1 === state.tracks.length) { + dispatch('radios/populateQueue', null, {root: true}) + } }, resume ({state, dispatch, rootState}) { 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..2ab8b708cd1ae140d31c7ca3c5e1cc3f56631f51 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" @@ -14,12 +14,6 @@ v-if="components[event.type]" :is="components[event.type]" :event="event"> - <username - class="user" - :username="event.actor.local_id" - slot="user"></username> - {{ event.published }} - <human-date class="date" :date="event.published" slot="date"></human-date> </component> </div> </div> diff --git a/front/src/views/playlists/Detail.vue b/front/src/views/playlists/Detail.vue index 2769317e6440f24c034f21d5db68bbd7fd52524b..61968c2e7e34683b03f9fa3bea2b2af437df3d18 100644 --- a/front/src/views/playlists/Detail.vue +++ b/front/src/views/playlists/Detail.vue @@ -1,6 +1,6 @@ <template> <div> - <div v-if="isLoading" class="ui vertical segment" v-title="'Playlist'"> + <div v-if="isLoading" class="ui vertical segment" v-title="$t('Playlist')"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment" v-title="playlist.name"> @@ -9,28 +9,28 @@ <i class="circular inverted list yellow icon"></i> <div class="content"> {{ playlist.name }} - <div class="sub header"> - Playlist containing {{ playlistTracks.length }} tracks, - by <username :username="playlist.user.username"></username> - </div> + <i18next tag="div" class="sub header" path="Playlist containing {%0%} tracks, by {%1%}"> + {{ playlistTracks.length }} + <username :username="playlist.user.username"></username> + </i18next> </div> </h2> <div class="ui hidden divider"></div> </button> - <play-button class="orange" :tracks="tracks">Play all</play-button> + <play-button class="orange" :tracks="tracks">{{ $t('Play all') }}</play-button> <button class="ui icon button" v-if="playlist.user.id === $store.state.auth.profile.id" @click="edit = !edit"> <i class="pencil icon"></i> - <template v-if="edit">End edition</template> - <template v-else>Edit...</template> + <template v-if="edit">{{ $t('End edition') }}</template> + <template v-else>{{ $t('Edit...') }}</template> </button> <dangerous-button class="labeled icon" :action="deletePlaylist"> - <i class="trash icon"></i> Delete - <p slot="modal-header">Do you want to delete the playlist "{{ playlist.name }}"?</p> - <p slot="modal-content">This will completely delete this playlist and cannot be undone.</p> - <p slot="modal-confirm">Delete playlist</p> + <i class="trash icon"></i> {{ $t('Delete') }} + <p slot="modal-header">{{ $t('Do you want to delete the playlist "{% playlist %}"?', {playlist: playlist.name}) }}</p> + <p slot="modal-content">{{ $t('This will completely delete this playlist and cannot be undone.') }}</p> + <p slot="modal-confirm">{{ $t('Delete playlist') }}</p> </dangerous-button> </div> </div> diff --git a/front/src/views/playlists/List.vue b/front/src/views/playlists/List.vue index 96aa36c4779bc11a6050208cf497ac861a7dabcd..32ee5aafaa9d28e34d1c824f479c6b0efea0447f 100644 --- a/front/src/views/playlists/List.vue +++ b/front/src/views/playlists/List.vue @@ -1,21 +1,21 @@ <template> - <div v-title="'Playlists'"> + <div v-title="$t('Playlists')"> <div class="ui vertical stripe segment"> - <h2 class="ui header">Browsing playlists</h2> + <h2 class="ui header">{{ $t('Browsing playlists') }}</h2> <div :class="['ui', {'loading': isLoading}, 'form']"> <template v-if="$store.state.auth.authenticated"> <button @click="$store.commit('playlists/chooseTrack', null)" - class="ui basic green button">Manage your playlists</button> + class="ui basic green button">{{ $t('Manage your playlists') }}</button> <div class="ui hidden divider"></div> </template> <div class="fields"> <div class="field"> - <label>Search</label> - <input type="text" v-model="query" placeholder="Enter an playlist name..."/> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="query" :placeholder="$t('Enter an playlist 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] }} @@ -23,14 +23,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> @@ -76,6 +76,7 @@ export default { Pagination }, data () { + console.log('YOLO', this.$t) let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') return { isLoading: true, diff --git a/front/src/views/radios/Detail.vue b/front/src/views/radios/Detail.vue index 397dcb49ec3fbd7e05fa45007f38418712a2d3b7..b3b500cf619d8b1809014609d9c77a94d5a0b6e3 100644 --- a/front/src/views/radios/Detail.vue +++ b/front/src/views/radios/Detail.vue @@ -83,7 +83,6 @@ export default { axios.get(url).then((response) => { self.radio = response.data axios.get(url + 'tracks', {params: {page: this.page}}).then((response) => { - console.log(response.data.count) this.totalTracks = response.data.count this.tracks = response.data.results }).then(() => { 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..3a59117d54f8d833f0a35ffe63884981849030fd 100644 --- a/front/test/unit/specs/store/queue.spec.js +++ b/front/test/unit/specs/store/queue.spec.js @@ -158,9 +158,7 @@ describe('store/queue', () => { payload: 1, params: {state: {currentIndex: 2}}, expectedMutations: [ - { type: 'splice', payload: {start: 1, size: 1} } - ], - expectedActions: [ + { type: 'splice', payload: {start: 1, size: 1} }, { type: 'currentIndex', payload: 1 } ] }, done) @@ -326,7 +324,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