Merge branch 'release/0.10'
This commit is contained in:
commit
0087ae4c80
1
.env.dev
1
.env.dev
|
@ -9,3 +9,4 @@ FUNKWHALE_HOSTNAME=localhost
|
|||
FUNKWHALE_PROTOCOL=http
|
||||
PYTHONDONTWRITEBYTECODE=true
|
||||
WEBPACK_DEVSERVER_PORT=8080
|
||||
MUSIC_DIRECTORY_PATH=/music
|
||||
|
|
|
@ -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
|
||||
|
|
82
CHANGELOG
82
CHANGELOG
|
@ -3,6 +3,88 @@ Changelog
|
|||
|
||||
.. towncrier
|
||||
|
||||
0.10 (2018-04-23)
|
||||
-----------------
|
||||
|
||||
Features:
|
||||
|
||||
- Can now import files in-place from the CLI importer (#155)
|
||||
|
||||
|
||||
Enhancements:
|
||||
|
||||
- Avoid downloading audio files multiple times from remote libraries (#163)
|
||||
- Better file import performance and error handling (#144)
|
||||
- Import job and batch API and front-end have been improved with better
|
||||
performance, pagination and additional filters (#171)
|
||||
- Increased max_length on TrackFile.source, this will help when importing files
|
||||
with a really long path (#142)
|
||||
- Player is back in Queue tab (#150)
|
||||
|
||||
|
||||
Bugfixes:
|
||||
|
||||
- Fail graciously when AP representation includes a null_value for mediaType
|
||||
- Fix sidebar tabs not showing under small resolution under Chrome (#173)
|
||||
- Fixed broken login due to badly configured Axios (#172)
|
||||
- Fixed broken playlist modal after login (#155)
|
||||
- Fixed queue reorder or track deletion restarting currently playing track
|
||||
(#151)
|
||||
- Radio will now append new track if you delete the last track in queue (#145)
|
||||
- Reset all sensitive front-end data on logout (#124)
|
||||
- Typos/not showing text due to i18n work (#175)
|
||||
|
||||
|
||||
Documentation:
|
||||
|
||||
- Better documentation for hardware requirements and memory usage (#165)
|
||||
|
||||
|
||||
In-place import
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
This release includes in-place imports for the CLI import. This means you can
|
||||
load gigabytes of music into funkwhale without worrying about about Funkwhale
|
||||
copying those music files in its internal storage and eating your disk space.
|
||||
|
||||
`This new feature is documented here <https://docs.funkwhale.audio/importing-music.html#in-place-import>`_
|
||||
and require additional configuration to ensure funkwhale and your webserver can
|
||||
serve those files properly.
|
||||
|
||||
**Non-docker users:**
|
||||
|
||||
Assuming your music is stored in ``/srv/funkwhale/data/music``, add the following
|
||||
block to your nginx configuration::
|
||||
|
||||
location /_protected/music {
|
||||
internal;
|
||||
alias /srv/funkwhale/data/music;
|
||||
}
|
||||
|
||||
And the following to your .env file::
|
||||
|
||||
MUSIC_DIRECTORY_PATH=/srv/funkwhale/data/music
|
||||
|
||||
**Docker users:**
|
||||
|
||||
Assuming your music is stored in ``/srv/funkwhale/data/music``, add the following
|
||||
block to your nginx configuration::
|
||||
|
||||
location /_protected/music {
|
||||
internal;
|
||||
alias /srv/funkwhale/data/music;
|
||||
}
|
||||
|
||||
Assuming you have the following volume directive in your ``docker-compose.yml``
|
||||
(it's the default): ``/srv/funkwhale/data/music:/music:ro``, then add
|
||||
the following to your .env file::
|
||||
|
||||
# this is the path in the container
|
||||
MUSIC_DIRECTORY_PATH=/music
|
||||
# this is the path on the host
|
||||
MUSIC_DIRECTORY_SERVE_PATH=/srv/funkwhale/data/music
|
||||
|
||||
|
||||
0.9.1 (2018-04-17)
|
||||
------------------
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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('.')])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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=
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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;
|
||||
|
|
5
dev.yml
5
dev.yml
|
@ -65,7 +65,7 @@ services:
|
|||
- "CACHE_URL=redis://redis:6379/0"
|
||||
volumes:
|
||||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
||||
networks:
|
||||
- internal
|
||||
api:
|
||||
|
@ -78,7 +78,7 @@ services:
|
|||
command: python /app/manage.py runserver 0.0.0.0:12081
|
||||
volumes:
|
||||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
||||
environment:
|
||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
||||
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
|
||||
|
@ -107,6 +107,7 @@ services:
|
|||
volumes:
|
||||
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
|
||||
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
|
||||
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
||||
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
|
||||
- ./api/funkwhale_api/media:/protected/media
|
||||
ports:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -18,12 +18,12 @@
|
|||
<div class="ui compact fluid two item inverted menu">
|
||||
<a class="active item" @click="selectedTab = 'library'" data-tab="library">Browse</a>
|
||||
<a class="item" @click="selectedTab = 'queue'" data-tab="queue">
|
||||
Queue
|
||||
{{ $t('Queue') }}
|
||||
<template v-if="queue.tracks.length === 0">
|
||||
(empty)
|
||||
{{ $t('(empty)') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
({{ queue.currentIndex + 1}} of {{ queue.tracks.length }})
|
||||
{{ $t('({%index%} of {%length%})', { index: queue.currentIndex + 1, length: queue.tracks.length }) }}
|
||||
</template>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -31,37 +31,35 @@
|
|||
<div class="tabs">
|
||||
<div class="ui bottom attached active tab" data-tab="library">
|
||||
<div class="ui inverted vertical fluid menu">
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i> Logged in as {{ $store.state.auth.username }}</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> Logout</router-link>
|
||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
|
||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> {{ $t('Logout') }}</router-link>
|
||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> {{ $t('Login') }}</router-link>
|
||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i> {{ $t('Favorites') }}</router-link>
|
||||
<a
|
||||
@click="$store.commit('playlists/chooseTrack', null)"
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item">
|
||||
<i class="list icon"></i> Playlists
|
||||
<i class="list icon"></i> {{ $t('Playlists') }}
|
||||
</a>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
|
||||
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> {{ $t('Activity') }}</router-link>
|
||||
<router-link
|
||||
class="item" v-if="$store.state.auth.availablePermissions['federation.manage']"
|
||||
:to="{path: '/manage/federation/libraries'}"><i class="sitemap icon"></i> Federation</router-link>
|
||||
:to="{path: '/manage/federation/libraries'}"><i class="sitemap icon"></i> {{ $t('Federation') }}</router-link>
|
||||
</div>
|
||||
|
||||
<player></player>
|
||||
</div>
|
||||
<div v-if="queue.previousQueue " class="ui black icon message">
|
||||
<i class="history icon"></i>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
Do you want to restore your previous queue?
|
||||
{{ $t('Do you want to restore your previous queue?') }}
|
||||
</div>
|
||||
<p>{{ queue.previousQueue.tracks.length }} tracks</p>
|
||||
<p>{{ $t('{%count%} tracks', { count: queue.previousQueue.tracks.length }) }}</p>
|
||||
<div class="ui two buttons">
|
||||
<div @click="queue.restore()" class="ui basic inverted green button">Yes</div>
|
||||
<div @click="queue.removePrevious()" class="ui basic inverted red button">No</div>
|
||||
<div @click="queue.restore()" class="ui basic inverted green button">{{ $t('Yes') }}</div>
|
||||
<div @click="queue.removePrevious()" class="ui basic inverted red button">{{ $t('No') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -90,17 +88,17 @@
|
|||
</draggable>
|
||||
</table>
|
||||
<div v-if="$store.state.radios.running" class="ui black message">
|
||||
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<i class="feed icon"></i> You have a radio playing
|
||||
<i class="feed icon"></i> {{ $t('You have a radio playing') }}
|
||||
</div>
|
||||
<p>New tracks will be appended here automatically.</p>
|
||||
<div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">Stop radio</div>
|
||||
<p>{{ $t('New tracks will be appended here automatically.') }}</p>
|
||||
<div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">{{ $t('Stop radio') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<player @next="scrollToCurrent" @previous="scrollToCurrent"></player>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -143,8 +141,9 @@ export default {
|
|||
...mapActions({
|
||||
cleanTrack: 'queue/cleanTrack'
|
||||
}),
|
||||
reorder: function (oldValue, newValue) {
|
||||
this.$store.commit('queue/reorder', {oldValue, newValue})
|
||||
reorder: function (event) {
|
||||
this.$store.commit('queue/reorder', {
|
||||
oldIndex: event.oldIndex, newIndex: event.newIndex})
|
||||
},
|
||||
scrollToCurrent () {
|
||||
let current = $(this.$el).find('[data-tab="queue"] .active')[0]
|
||||
|
@ -159,7 +158,6 @@ export default {
|
|||
// for half the height of the containers display area
|
||||
var scrollBack = (container.scrollHeight - container.scrollTop <= container.clientHeight) ? 0 : container.clientHeight / 2
|
||||
container.scrollTop = container.scrollTop - scrollBack
|
||||
console.log(container.scrollHeight - container.scrollTop, container.clientHeight)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -239,9 +237,6 @@ $sidebar-color: #3D3E3F;
|
|||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
justify-content: space-between;
|
||||
@include media(">tablet") {
|
||||
height: 0px;
|
||||
}
|
||||
@include media("<desktop") {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
<template>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<h4 v-if="title" class="ui header">Create a new playlist</h4>
|
||||
<h4 v-if="title" class="ui header">{{ $t('Create a new playlist') }}</h4>
|
||||
<div v-if="success" class="ui positive message">
|
||||
<div class="header">
|
||||
<template v-if="playlist">
|
||||
Playlist updated
|
||||
{{ $t('Playlist updated') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Playlist created
|
||||
{{ $t('Playlist created') }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">We cannot create the playlist</div>
|
||||
<div class="header">{{ $t('We cannot create the playlist') }}</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="three fields">
|
||||
<div class="field">
|
||||
<label>Playlist name</label>
|
||||
<label>{{ $t('Playlist name') }}</label>
|
||||
<input v-model="name" required type="text" placeholder="My awesome playlist" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Playlist visibility</label>
|
||||
<label>{{ $t('Playlist visibility') }}</label>
|
||||
<select class="ui dropdown" v-model="privacyLevel">
|
||||
<option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option>
|
||||
</select>
|
||||
|
@ -31,8 +31,8 @@
|
|||
<div class="field">
|
||||
<label> </label>
|
||||
<button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit">
|
||||
<template v-if="playlist">Update playlist</template>
|
||||
<template v-else>Create playlist</template>
|
||||
<template v-if="playlist">{{ $t('Update playlist') }}</template>
|
||||
<template v-else>{{ $t('Create playlist') }}</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -57,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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -20,6 +20,10 @@ export default {
|
|||
}
|
||||
}
|
||||
state.count = state.tracks.length
|
||||
},
|
||||
reset (state) {
|
||||
state.tracks = []
|
||||
state.count = 0
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -17,6 +17,11 @@ export default {
|
|||
},
|
||||
showModal (state, value) {
|
||||
state.showModal = value
|
||||
},
|
||||
reset (state) {
|
||||
state.playlists = []
|
||||
state.modalTrack = null
|
||||
state.showModal = false
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
|
|
@ -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}) {
|
||||
|
|
|
@ -26,6 +26,10 @@ export default {
|
|||
}
|
||||
},
|
||||
mutations: {
|
||||
reset (state) {
|
||||
state.running = false
|
||||
state.current = false
|
||||
},
|
||||
current: (state, value) => {
|
||||
state.current = value
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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} },
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -97,6 +97,5 @@ describe('store/radios', () => {
|
|||
expectedActions: []
|
||||
}, done)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue