Merge branch 'release/0.10'

This commit is contained in:
Eliot Berriot 2018-04-23 19:44:47 +02:00
commit 0087ae4c80
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
78 changed files with 1372 additions and 469 deletions

View File

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

View File

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

View File

@ -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)
------------------

View File

@ -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)

View File

@ -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('.')])

View File

@ -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

View File

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

View File

@ -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')

View File

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

View File

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

View File

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

View File

@ -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):

View File

@ -71,7 +71,7 @@ def import_track_from_remote(library_track):
library_track.title, artist=artist, album=album)
def _do_import(import_job, replace, use_acoustid=True):
def _do_import(import_job, replace=False, use_acoustid=True):
from_file = bool(import_job.audio_file)
mbid = import_job.mbid
acoustid_track_id = None
@ -93,6 +93,9 @@ def _do_import(import_job, replace, use_acoustid=True):
track = import_track_data_from_path(import_job.audio_file.path)
elif import_job.library_track:
track = import_track_from_remote(import_job.library_track)
elif import_job.source.startswith('file://'):
track = import_track_data_from_path(
import_job.source.replace('file://', '', 1))
else:
raise ValueError(
'Not enough data to process import, '
@ -123,7 +126,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:

View File

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

View File

@ -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)

View File

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

View File

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

View File

@ -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

View File

@ -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)

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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

View File

@ -1,17 +1,22 @@
# If you have any doubts about what a setting does,
# check https://docs.funkwhale.audio/configuration.html#configuration-reference
# If you're tweaking this file from the template, ensure you edit at least the
# following variables:
# - DJANGO_SECRET_KEY
# - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL
# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL
# - CACHE_URL
# - STATIC_ROOT
# - MEDIA_ROOT
#
# You **don't** need to update those variables on pure docker setups.
#
# Additional options you may want to check:
# - MUSIC_DIRECTORY_PATH and MUSIC_DIRECTORY_SERVE_PATH if you plan to use
# in-place import
# Docker only
# -----------
@ -19,7 +24,9 @@
# (it will be interpolated in docker-compose file)
# You can comment or ignore this if you're not using docker
FUNKWHALE_VERSION=latest
MUSIC_DIRECTORY_PATH=/music
# End of Docker-only configuration
# General configuration
# ---------------------
@ -34,6 +41,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=

View File

@ -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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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 &nbsp;
{{ $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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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) {

View File

@ -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>

View File

@ -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
}
}
}

View File

@ -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 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>
<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
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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: {

View File

@ -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>

View File

@ -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">

View File

@ -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>&nbsp;</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,21 +57,7 @@ export default {
let d = {
errors: [],
success: false,
isLoading: false,
privacyLevelChoices: [
{
value: 'me',
label: 'Nobody except me'
},
{
value: 'instance',
label: 'Everyone on this instance'
},
{
value: 'everyone',
label: 'Everyone'
}
]
isLoading: false
}
if (this.playlist) {
d.name = this.playlist.name
@ -82,6 +68,24 @@ export default {
}
return d
},
computed: {
privacyLevelChoices: function () {
return [
{
value: 'me',
label: this.$t('Nobody except me')
},
{
value: 'instance',
label: this.$t('Everyone on this instance')
},
{
value: 'everyone',
label: this.$t('Everyone')
}
]
}
},
methods: {
submit () {
this.isLoading = true

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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()

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,12 @@ export default {
previousQueue: null
},
mutations: {
reset (state) {
state.tracks = []
state.currentIndex = -1
state.ended = true
state.previousQueue = null
},
currentIndex (state, value) {
state.currentIndex = value
},
@ -86,14 +92,17 @@ export default {
if (current) {
dispatch('player/stop', null, {root: true})
}
if (index < state.currentIndex) {
dispatch('currentIndex', state.currentIndex - 1)
}
commit('splice', {start: index, size: 1})
if (index < state.currentIndex) {
commit('currentIndex', state.currentIndex - 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}) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View File

@ -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,

View File

@ -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(() => {

View File

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

View File

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

View File

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

View File

@ -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: [

View File

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

View File

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