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
|
FUNKWHALE_PROTOCOL=http
|
||||||
PYTHONDONTWRITEBYTECODE=true
|
PYTHONDONTWRITEBYTECODE=true
|
||||||
WEBPACK_DEVSERVER_PORT=8080
|
WEBPACK_DEVSERVER_PORT=8080
|
||||||
|
MUSIC_DIRECTORY_PATH=/music
|
||||||
|
|
|
@ -70,7 +70,9 @@ build_front:
|
||||||
- yarn install
|
- yarn install
|
||||||
- yarn run i18n-extract
|
- yarn run i18n-extract
|
||||||
- yarn run i18n-compile
|
- yarn run i18n-compile
|
||||||
- yarn run build
|
# this is to ensure we don't have any errors in the output,
|
||||||
|
# cf https://code.eliotberriot.com/funkwhale/funkwhale/issues/169
|
||||||
|
- yarn run build | tee /dev/stderr | (! grep -i 'ERROR in')
|
||||||
cache:
|
cache:
|
||||||
key: "$CI_PROJECT_ID__front_dependencies"
|
key: "$CI_PROJECT_ID__front_dependencies"
|
||||||
paths:
|
paths:
|
||||||
|
@ -81,9 +83,9 @@ build_front:
|
||||||
paths:
|
paths:
|
||||||
- front/dist/
|
- front/dist/
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags@funkwhale/funkwhale
|
||||||
- master
|
- master@funkwhale/funkwhale
|
||||||
- develop
|
- develop@funkwhale/funkwhale
|
||||||
tags:
|
tags:
|
||||||
- docker
|
- docker
|
||||||
|
|
||||||
|
@ -100,7 +102,7 @@ pages:
|
||||||
paths:
|
paths:
|
||||||
- public
|
- public
|
||||||
only:
|
only:
|
||||||
- develop
|
- develop@funkwhale/funkwhale
|
||||||
tags:
|
tags:
|
||||||
- docker
|
- docker
|
||||||
|
|
||||||
|
@ -114,7 +116,7 @@ docker_develop:
|
||||||
- docker build -t $IMAGE .
|
- docker build -t $IMAGE .
|
||||||
- docker push $IMAGE
|
- docker push $IMAGE
|
||||||
only:
|
only:
|
||||||
- develop
|
- develop@funkwhale/funkwhale
|
||||||
tags:
|
tags:
|
||||||
- dind
|
- dind
|
||||||
|
|
||||||
|
@ -128,9 +130,9 @@ build_api:
|
||||||
- api
|
- api
|
||||||
script: echo Done!
|
script: echo Done!
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags@funkwhale/funkwhale
|
||||||
- master
|
- master@funkwhale/funkwhale
|
||||||
- develop
|
- develop@funkwhale/funkwhale
|
||||||
|
|
||||||
|
|
||||||
docker_release:
|
docker_release:
|
||||||
|
@ -144,6 +146,6 @@ docker_release:
|
||||||
- docker push $IMAGE
|
- docker push $IMAGE
|
||||||
- docker push $IMAGE_LATEST
|
- docker push $IMAGE_LATEST
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags@funkwhale/funkwhale
|
||||||
tags:
|
tags:
|
||||||
- dind
|
- dind
|
||||||
|
|
82
CHANGELOG
82
CHANGELOG
|
@ -3,6 +3,88 @@ Changelog
|
||||||
|
|
||||||
.. towncrier
|
.. 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)
|
0.9.1 (2018-04-17)
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
|
|
|
@ -390,6 +390,12 @@ REST_FRAMEWORK = {
|
||||||
ATOMIC_REQUESTS = False
|
ATOMIC_REQUESTS = False
|
||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
USE_X_FORWARDED_PORT = 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
|
# Wether we should check user permission before serving audio files (meaning
|
||||||
# return an obfuscated url)
|
# return an obfuscated url)
|
||||||
# This require a special configuration on the reverse proxy side
|
# This require a special configuration on the reverse proxy side
|
||||||
|
@ -441,3 +447,9 @@ EXTERNAL_REQUESTS_VERIFY_SSL = env.bool(
|
||||||
'EXTERNAL_REQUESTS_VERIFY_SSL',
|
'EXTERNAL_REQUESTS_VERIFY_SSL',
|
||||||
default=True
|
default=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MUSIC_DIRECTORY_PATH = env('MUSIC_DIRECTORY_PATH', default=None)
|
||||||
|
# on Docker setup, the music directory may not match the host path,
|
||||||
|
# and we need to know it for it to serve stuff properly
|
||||||
|
MUSIC_DIRECTORY_SERVE_PATH = env(
|
||||||
|
'MUSIC_DIRECTORY_SERVE_PATH', default=MUSIC_DIRECTORY_PATH)
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- 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('.')])
|
__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):
|
except (KeyError, TypeError):
|
||||||
raise serializers.ValidationError('Missing mediaType')
|
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 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/'):
|
|
||||||
raise serializers.ValidationError('Invalid mediaType')
|
raise serializers.ValidationError('Invalid mediaType')
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
|
@ -43,6 +43,7 @@ class TrackFactory(factory.django.DjangoModelFactory):
|
||||||
artist = factory.SelfAttribute('album.artist')
|
artist = factory.SelfAttribute('album.artist')
|
||||||
position = 1
|
position = 1
|
||||||
tags = ManyToManyFromList('tags')
|
tags = ManyToManyFromList('tags')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'music.Track'
|
model = 'music.Track'
|
||||||
|
|
||||||
|
@ -57,6 +58,9 @@ class TrackFileFactory(factory.django.DjangoModelFactory):
|
||||||
model = 'music.TrackFile'
|
model = 'music.TrackFile'
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
|
in_place = factory.Trait(
|
||||||
|
audio_file=None,
|
||||||
|
)
|
||||||
federation = factory.Trait(
|
federation = factory.Trait(
|
||||||
audio_file=None,
|
audio_file=None,
|
||||||
library_track=factory.SubFactory(LibraryTrackFactory),
|
library_track=factory.SubFactory(LibraryTrackFactory),
|
||||||
|
@ -105,6 +109,10 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
|
||||||
status='finished',
|
status='finished',
|
||||||
track_file=factory.SubFactory(TrackFileFactory),
|
track_file=factory.SubFactory(TrackFileFactory),
|
||||||
)
|
)
|
||||||
|
in_place = factory.Trait(
|
||||||
|
status='finished',
|
||||||
|
audio_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='music.FileImportJob')
|
@registry.register(name='music.FileImportJob')
|
||||||
|
|
|
@ -2,6 +2,7 @@ from django.db.models import Count
|
||||||
|
|
||||||
from django_filters import rest_framework as filters
|
from django_filters import rest_framework as filters
|
||||||
|
|
||||||
|
from funkwhale_api.common import fields
|
||||||
from . import models
|
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):
|
class AlbumFilter(ListenableMixin):
|
||||||
listenable = filters.BooleanFilter(name='_', method='filter_listenable')
|
listenable = filters.BooleanFilter(name='_', method='filter_listenable')
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import mutagen
|
from django import forms
|
||||||
import arrow
|
import arrow
|
||||||
|
import mutagen
|
||||||
|
|
||||||
NODEFAULT = object()
|
NODEFAULT = object()
|
||||||
|
|
||||||
|
@ -50,6 +51,13 @@ def convert_track_number(v):
|
||||||
except (ValueError, AttributeError, IndexError):
|
except (ValueError, AttributeError, IndexError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
VALIDATION = {
|
||||||
|
'musicbrainz_artistid': forms.UUIDField(),
|
||||||
|
'musicbrainz_albumid': forms.UUIDField(),
|
||||||
|
'musicbrainz_recordingid': forms.UUIDField(),
|
||||||
|
}
|
||||||
|
|
||||||
CONF = {
|
CONF = {
|
||||||
'OggVorbis': {
|
'OggVorbis': {
|
||||||
'getter': lambda f, k: f[k][0],
|
'getter': lambda f, k: f[k][0],
|
||||||
|
@ -146,4 +154,7 @@ class Metadata(object):
|
||||||
converter = field_conf.get('to_application')
|
converter = field_conf.get('to_application')
|
||||||
if converter:
|
if converter:
|
||||||
v = converter(v)
|
v = converter(v)
|
||||||
|
field = VALIDATION.get(key)
|
||||||
|
if field:
|
||||||
|
v = field.to_python(v)
|
||||||
return v
|
return v
|
||||||
|
|
|
@ -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 = models.ForeignKey(
|
||||||
Track, related_name='files', on_delete=models.CASCADE)
|
Track, related_name='files', on_delete=models.CASCADE)
|
||||||
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
|
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
|
||||||
source = models.URLField(null=True, blank=True)
|
source = models.URLField(null=True, blank=True, max_length=500)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
modification_date = models.DateTimeField(auto_now=True)
|
modification_date = models.DateTimeField(auto_now=True)
|
||||||
duration = models.IntegerField(null=True, blank=True)
|
duration = models.IntegerField(null=True, blank=True)
|
||||||
|
@ -463,6 +463,26 @@ class TrackFile(models.Model):
|
||||||
self.mimetype = utils.guess_mimetype(self.audio_file)
|
self.mimetype = utils.guess_mimetype(self.audio_file)
|
||||||
return super().save(**kwargs)
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serve_from_source_path(self):
|
||||||
|
if not self.source or not self.source.startswith('file://'):
|
||||||
|
raise ValueError('Cannot serve this file from source')
|
||||||
|
serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
|
||||||
|
prefix = settings.MUSIC_DIRECTORY_PATH
|
||||||
|
if not serve_path or not prefix:
|
||||||
|
raise ValueError(
|
||||||
|
'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
|
||||||
|
'MUSIC_DIRECTORY_PATH to serve in-place imported files'
|
||||||
|
)
|
||||||
|
file_path = self.source.replace('file://', '', 1)
|
||||||
|
parts = os.path.split(file_path.replace(prefix, '', 1))
|
||||||
|
if parts[0] == '/':
|
||||||
|
parts = parts[1:]
|
||||||
|
return os.path.join(
|
||||||
|
serve_path,
|
||||||
|
*parts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
IMPORT_STATUS_CHOICES = (
|
IMPORT_STATUS_CHOICES = (
|
||||||
('pending', 'Pending'),
|
('pending', 'Pending'),
|
||||||
|
@ -507,6 +527,8 @@ class ImportBatch(models.Model):
|
||||||
def update_status(self):
|
def update_status(self):
|
||||||
old_status = self.status
|
old_status = self.status
|
||||||
self.status = utils.compute_status(self.jobs.all())
|
self.status = utils.compute_status(self.jobs.all())
|
||||||
|
if self.status == old_status:
|
||||||
|
return
|
||||||
self.save(update_fields=['status'])
|
self.save(update_fields=['status'])
|
||||||
if self.status != old_status and self.status == 'finished':
|
if self.status != old_status and self.status == 'finished':
|
||||||
from . import tasks
|
from . import tasks
|
||||||
|
|
|
@ -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 import utils as federation_utils
|
||||||
from funkwhale_api.federation.models import LibraryTrack
|
from funkwhale_api.federation.models import LibraryTrack
|
||||||
from funkwhale_api.federation.serializers import AP_CONTEXT
|
from funkwhale_api.federation.serializers import AP_CONTEXT
|
||||||
|
from funkwhale_api.users.serializers import UserBasicSerializer
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
@ -90,6 +91,7 @@ class TrackSerializerNested(LyricsMixin):
|
||||||
files = TrackFileSerializer(many=True, read_only=True)
|
files = TrackFileSerializer(many=True, read_only=True)
|
||||||
album = SimpleAlbumSerializer(read_only=True)
|
album = SimpleAlbumSerializer(read_only=True)
|
||||||
tags = TagSerializer(many=True, read_only=True)
|
tags = TagSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Track
|
model = models.Track
|
||||||
fields = ('id', 'mbid', 'title', 'artist', 'files', 'album', 'tags', 'lyrics')
|
fields = ('id', 'mbid', 'title', 'artist', 'files', 'album', 'tags', 'lyrics')
|
||||||
|
@ -108,6 +110,7 @@ class AlbumSerializerNested(serializers.ModelSerializer):
|
||||||
class ArtistSerializerNested(serializers.ModelSerializer):
|
class ArtistSerializerNested(serializers.ModelSerializer):
|
||||||
albums = AlbumSerializerNested(many=True, read_only=True)
|
albums = AlbumSerializerNested(many=True, read_only=True)
|
||||||
tags = TagSerializer(many=True, read_only=True)
|
tags = TagSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Artist
|
model = models.Artist
|
||||||
fields = ('id', 'mbid', 'name', 'albums', 'tags')
|
fields = ('id', 'mbid', 'name', 'albums', 'tags')
|
||||||
|
@ -121,18 +124,43 @@ class LyricsSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class ImportJobSerializer(serializers.ModelSerializer):
|
class ImportJobSerializer(serializers.ModelSerializer):
|
||||||
track_file = TrackFileSerializer(read_only=True)
|
track_file = TrackFileSerializer(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ImportJob
|
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')
|
read_only_fields = ('status', 'track_file')
|
||||||
|
|
||||||
|
|
||||||
class ImportBatchSerializer(serializers.ModelSerializer):
|
class ImportBatchSerializer(serializers.ModelSerializer):
|
||||||
jobs = ImportJobSerializer(many=True, read_only=True)
|
submitted_by = UserBasicSerializer(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ImportBatch
|
model = models.ImportBatch
|
||||||
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
|
fields = (
|
||||||
read_only_fields = ('creation_date',)
|
'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):
|
class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
|
|
|
@ -71,7 +71,7 @@ def import_track_from_remote(library_track):
|
||||||
library_track.title, artist=artist, album=album)
|
library_track.title, artist=artist, album=album)
|
||||||
|
|
||||||
|
|
||||||
def _do_import(import_job, replace, use_acoustid=True):
|
def _do_import(import_job, replace=False, use_acoustid=True):
|
||||||
from_file = bool(import_job.audio_file)
|
from_file = bool(import_job.audio_file)
|
||||||
mbid = import_job.mbid
|
mbid = import_job.mbid
|
||||||
acoustid_track_id = None
|
acoustid_track_id = None
|
||||||
|
@ -93,6 +93,9 @@ def _do_import(import_job, replace, use_acoustid=True):
|
||||||
track = import_track_data_from_path(import_job.audio_file.path)
|
track = import_track_data_from_path(import_job.audio_file.path)
|
||||||
elif import_job.library_track:
|
elif import_job.library_track:
|
||||||
track = import_track_from_remote(import_job.library_track)
|
track = import_track_from_remote(import_job.library_track)
|
||||||
|
elif import_job.source.startswith('file://'):
|
||||||
|
track = import_track_data_from_path(
|
||||||
|
import_job.source.replace('file://', '', 1))
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'Not enough data to process import, '
|
'Not enough data to process import, '
|
||||||
|
@ -123,7 +126,8 @@ def _do_import(import_job, replace, use_acoustid=True):
|
||||||
else:
|
else:
|
||||||
# no downloading, we hotlink
|
# no downloading, we hotlink
|
||||||
pass
|
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.download_file()
|
||||||
track_file.save()
|
track_file.save()
|
||||||
import_job.status = 'finished'
|
import_job.status = 'finished'
|
||||||
|
@ -133,7 +137,7 @@ def _do_import(import_job, replace, use_acoustid=True):
|
||||||
import_job.audio_file.delete()
|
import_job.audio_file.delete()
|
||||||
import_job.save()
|
import_job.save()
|
||||||
|
|
||||||
return track.pk
|
return track_file
|
||||||
|
|
||||||
|
|
||||||
@celery.app.task(name='ImportJob.run', bind=True)
|
@celery.app.task(name='ImportJob.run', bind=True)
|
||||||
|
@ -147,7 +151,8 @@ def import_job_run(self, import_job, replace=False, use_acoustid=True):
|
||||||
import_job.save(update_fields=['status'])
|
import_job.save(update_fields=['status'])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return _do_import(import_job, replace, use_acoustid=use_acoustid)
|
tf = _do_import(import_job, replace, use_acoustid=use_acoustid)
|
||||||
|
return tf.pk if tf else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -53,10 +53,11 @@ def guess_mimetype(f):
|
||||||
|
|
||||||
|
|
||||||
def compute_status(jobs):
|
def compute_status(jobs):
|
||||||
errored = any([job.status == 'errored' for job in jobs])
|
statuses = jobs.order_by().values_list('status', flat=True).distinct()
|
||||||
|
errored = any([status == 'errored' for status in statuses])
|
||||||
if errored:
|
if errored:
|
||||||
return 'errored'
|
return 'errored'
|
||||||
pending = any([job.status == 'pending' for job in jobs])
|
pending = any([status == 'pending' for status in statuses])
|
||||||
if pending:
|
if pending:
|
||||||
return 'pending'
|
return 'pending'
|
||||||
return 'finished'
|
return 'finished'
|
||||||
|
|
|
@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models.functions import Length
|
from django.db.models.functions import Length
|
||||||
|
from django.db.models import Count
|
||||||
from django.http import StreamingHttpResponse
|
from django.http import StreamingHttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
|
@ -23,13 +24,14 @@ from rest_framework import permissions
|
||||||
from musicbrainzngs import ResponseError
|
from musicbrainzngs import ResponseError
|
||||||
|
|
||||||
from funkwhale_api.common import utils as funkwhale_utils
|
from funkwhale_api.common import utils as funkwhale_utils
|
||||||
from funkwhale_api.federation import actors
|
|
||||||
from funkwhale_api.requests.models import ImportRequest
|
|
||||||
from funkwhale_api.musicbrainz import api
|
|
||||||
from funkwhale_api.common.permissions import (
|
from funkwhale_api.common.permissions import (
|
||||||
ConditionalAuthentication, HasModelPermission)
|
ConditionalAuthentication, HasModelPermission)
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
from funkwhale_api.federation import actors
|
||||||
from funkwhale_api.federation.authentication import SignatureAuthentication
|
from funkwhale_api.federation.authentication import SignatureAuthentication
|
||||||
|
from funkwhale_api.federation.models import LibraryTrack
|
||||||
|
from funkwhale_api.musicbrainz import api
|
||||||
|
from funkwhale_api.requests.models import ImportRequest
|
||||||
|
|
||||||
from . import filters
|
from . import filters
|
||||||
from . import forms
|
from . import forms
|
||||||
|
@ -98,14 +100,14 @@ class ImportBatchViewSet(
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
viewsets.GenericViewSet):
|
viewsets.GenericViewSet):
|
||||||
queryset = (
|
queryset = (
|
||||||
models.ImportBatch.objects.all()
|
models.ImportBatch.objects
|
||||||
.prefetch_related('jobs__track_file')
|
.select_related()
|
||||||
.order_by('-creation_date'))
|
.order_by('-creation_date')
|
||||||
|
.annotate(job_count=Count('jobs'))
|
||||||
|
)
|
||||||
serializer_class = serializers.ImportBatchSerializer
|
serializer_class = serializers.ImportBatchSerializer
|
||||||
permission_classes = (permissions.DjangoModelPermissions, )
|
permission_classes = (permissions.DjangoModelPermissions, )
|
||||||
|
filter_class = filters.ImportBatchFilter
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset().filter(submitted_by=self.request.user)
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(submitted_by=self.request.user)
|
serializer.save(submitted_by=self.request.user)
|
||||||
|
@ -118,13 +120,30 @@ class ImportJobPermission(HasModelPermission):
|
||||||
|
|
||||||
class ImportJobViewSet(
|
class ImportJobViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
mixins.ListModelMixin,
|
||||||
viewsets.GenericViewSet):
|
viewsets.GenericViewSet):
|
||||||
queryset = (models.ImportJob.objects.all())
|
queryset = (models.ImportJob.objects.all().select_related())
|
||||||
serializer_class = serializers.ImportJobSerializer
|
serializer_class = serializers.ImportJobSerializer
|
||||||
permission_classes = (ImportJobPermission, )
|
permission_classes = (ImportJobPermission, )
|
||||||
|
filter_class = filters.ImportJobFilter
|
||||||
|
|
||||||
def get_queryset(self):
|
@list_route(methods=['get'])
|
||||||
return super().get_queryset().filter(batch__submitted_by=self.request.user)
|
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):
|
def perform_create(self, serializer):
|
||||||
source = 'file://' + serializer.validated_data['audio_file'].name
|
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.
|
A simple ViewSet for viewing and editing accounts.
|
||||||
"""
|
"""
|
||||||
|
@ -185,6 +205,25 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
return Response(serializer.data)
|
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):
|
class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
queryset = (models.TrackFile.objects.all().order_by('-id'))
|
||||||
serializer_class = serializers.TrackFileSerializer
|
serializer_class = serializers.TrackFileSerializer
|
||||||
|
@ -195,12 +234,13 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
|
||||||
@detail_route(methods=['get'])
|
@detail_route(methods=['get'])
|
||||||
def serve(self, request, *args, **kwargs):
|
def serve(self, request, *args, **kwargs):
|
||||||
try:
|
queryset = models.TrackFile.objects.select_related(
|
||||||
f = models.TrackFile.objects.select_related(
|
|
||||||
'library_track',
|
'library_track',
|
||||||
'track__album__artist',
|
'track__album__artist',
|
||||||
'track__artist',
|
'track__artist',
|
||||||
).get(pk=kwargs['pk'])
|
)
|
||||||
|
try:
|
||||||
|
f = queryset.get(pk=kwargs['pk'])
|
||||||
except models.TrackFile.DoesNotExist:
|
except models.TrackFile.DoesNotExist:
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
|
|
||||||
|
@ -213,14 +253,29 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
if library_track and not audio_file:
|
if library_track and not audio_file:
|
||||||
if not library_track.audio_file:
|
if not library_track.audio_file:
|
||||||
# we need to populate from cache
|
# we need to populate from cache
|
||||||
|
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()
|
library_track.download_audio()
|
||||||
audio_file = library_track.audio_file
|
audio_file = library_track.audio_file
|
||||||
|
file_path = get_file_path(audio_file)
|
||||||
mt = library_track.audio_mimetype
|
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()
|
response = Response()
|
||||||
filename = f.filename
|
filename = f.filename
|
||||||
response['X-Accel-Redirect'] = "{}{}".format(
|
mapping = {
|
||||||
settings.PROTECT_FILES_PATH,
|
'nginx': 'X-Accel-Redirect',
|
||||||
audio_file.url)
|
'apache2': 'X-Sendfile',
|
||||||
|
}
|
||||||
|
file_header = mapping[settings.REVERSE_PROXY_TYPE]
|
||||||
|
response[file_header] = file_path
|
||||||
filename = "filename*=UTF-8''{}".format(
|
filename = "filename*=UTF-8''{}".format(
|
||||||
urllib.parse.quote(filename))
|
urllib.parse.quote(filename))
|
||||||
response["Content-Disposition"] = "attachment; {}".format(filename)
|
response["Content-Disposition"] = "attachment; {}".format(filename)
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from funkwhale_api.common import utils
|
from funkwhale_api.music import models
|
||||||
from funkwhale_api.music import tasks
|
from funkwhale_api.music import tasks
|
||||||
from funkwhale_api.users.models import User
|
from funkwhale_api.users.models import User
|
||||||
|
|
||||||
|
@ -39,7 +39,20 @@ class Command(BaseCommand):
|
||||||
action='store_true',
|
action='store_true',
|
||||||
dest='exit_on_failure',
|
dest='exit_on_failure',
|
||||||
default=False,
|
default=False,
|
||||||
help='use this flag to disable error catching',
|
help='Use this flag to disable error catching',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--in-place', '-i',
|
||||||
|
action='store_true',
|
||||||
|
dest='in_place',
|
||||||
|
default=False,
|
||||||
|
help=(
|
||||||
|
'Import files without duplicating them into the media directory.'
|
||||||
|
'For in-place import to work, the music files must be readable'
|
||||||
|
'by the web-server and funkwhale api and celeryworker processes.'
|
||||||
|
'You may want to use this if you have a big music library to '
|
||||||
|
'import and not much disk space available.'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--no-acoustid',
|
'--no-acoustid',
|
||||||
|
@ -54,21 +67,29 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# self.stdout.write(self.style.SUCCESS('Successfully closed poll "%s"' % poll_id))
|
|
||||||
|
|
||||||
# Recursive is supported only on Python 3.5+, so we pass the option
|
|
||||||
# only if it's True to avoid breaking on older versions of Python
|
|
||||||
glob_kwargs = {}
|
glob_kwargs = {}
|
||||||
if options['recursive']:
|
if options['recursive']:
|
||||||
glob_kwargs['recursive'] = True
|
glob_kwargs['recursive'] = True
|
||||||
try:
|
try:
|
||||||
matching = glob.glob(options['path'], **glob_kwargs)
|
matching = sorted(glob.glob(options['path'], **glob_kwargs))
|
||||||
except TypeError:
|
except TypeError:
|
||||||
raise Exception('You need Python 3.5 to use the --recursive flag')
|
raise Exception('You need Python 3.5 to use the --recursive flag')
|
||||||
|
|
||||||
self.stdout.write('This will import {} files matching this pattern: {}'.format(
|
if options['in_place']:
|
||||||
len(matching), options['path']))
|
self.stdout.write(
|
||||||
|
'Checking imported paths against settings.MUSIC_DIRECTORY_PATH')
|
||||||
|
p = settings.MUSIC_DIRECTORY_PATH
|
||||||
|
if not p:
|
||||||
|
raise CommandError(
|
||||||
|
'Importing in-place requires setting the '
|
||||||
|
'MUSIC_DIRECTORY_PATH variable')
|
||||||
|
for m in matching:
|
||||||
|
if not m.startswith(p):
|
||||||
|
raise CommandError(
|
||||||
|
'Importing in-place only works if importing'
|
||||||
|
'from {} (MUSIC_DIRECTORY_PATH), as this directory'
|
||||||
|
'needs to be accessible by the webserver.'
|
||||||
|
'Culprit: {}'.format(p, m))
|
||||||
if not matching:
|
if not matching:
|
||||||
raise CommandError('No file matching pattern, aborting')
|
raise CommandError('No file matching pattern, aborting')
|
||||||
|
|
||||||
|
@ -86,6 +107,24 @@ class Command(BaseCommand):
|
||||||
except AssertionError:
|
except AssertionError:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
'No superuser available, please provide a --username')
|
'No superuser available, please provide a --username')
|
||||||
|
|
||||||
|
filtered = self.filter_matching(matching, options)
|
||||||
|
self.stdout.write('Import summary:')
|
||||||
|
self.stdout.write('- {} files found matching this pattern: {}'.format(
|
||||||
|
len(matching), options['path']))
|
||||||
|
self.stdout.write('- {} files already found in database'.format(
|
||||||
|
len(filtered['skipped'])))
|
||||||
|
self.stdout.write('- {} new files'.format(
|
||||||
|
len(filtered['new'])))
|
||||||
|
|
||||||
|
self.stdout.write('Selected options: {}'.format(', '.join([
|
||||||
|
'no acoustid' if options['no_acoustid'] else 'use acoustid',
|
||||||
|
'in place' if options['in_place'] else 'copy music files',
|
||||||
|
])))
|
||||||
|
if len(filtered['new']) == 0:
|
||||||
|
self.stdout.write('Nothing new to import, exiting')
|
||||||
|
return
|
||||||
|
|
||||||
if options['interactive']:
|
if options['interactive']:
|
||||||
message = (
|
message = (
|
||||||
'Are you sure you want to do this?\n\n'
|
'Are you sure you want to do this?\n\n'
|
||||||
|
@ -94,27 +133,52 @@ class Command(BaseCommand):
|
||||||
if input(''.join(message)) != 'yes':
|
if input(''.join(message)) != 'yes':
|
||||||
raise CommandError("Import cancelled.")
|
raise CommandError("Import cancelled.")
|
||||||
|
|
||||||
batch = self.do_import(matching, user=user, options=options)
|
batch, errors = self.do_import(
|
||||||
|
filtered['new'], user=user, options=options)
|
||||||
message = 'Successfully imported {} tracks'
|
message = 'Successfully imported {} tracks'
|
||||||
if options['async']:
|
if options['async']:
|
||||||
message = 'Successfully launched import for {} tracks'
|
message = 'Successfully launched import for {} tracks'
|
||||||
self.stdout.write(message.format(len(matching)))
|
|
||||||
|
self.stdout.write(message.format(len(filtered['new'])))
|
||||||
|
if len(errors) > 0:
|
||||||
|
self.stderr.write(
|
||||||
|
'{} tracks could not be imported:'.format(len(errors)))
|
||||||
|
|
||||||
|
for path, error in errors:
|
||||||
|
self.stderr.write('- {}: {}'.format(path, error))
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
"For details, please refer to import batch #{}".format(batch.pk))
|
"For details, please refer to import batch #{}".format(batch.pk))
|
||||||
|
|
||||||
@transaction.atomic
|
def filter_matching(self, matching, options):
|
||||||
def do_import(self, matching, user, options):
|
sources = ['file://{}'.format(p) for p in matching]
|
||||||
message = 'Importing {}...'
|
# we skip reimport for path that are already found
|
||||||
|
# as a TrackFile.source
|
||||||
|
existing = models.TrackFile.objects.filter(source__in=sources)
|
||||||
|
existing = existing.values_list('source', flat=True)
|
||||||
|
existing = set([p.replace('file://', '', 1) for p in existing])
|
||||||
|
skipped = set(matching) & existing
|
||||||
|
result = {
|
||||||
|
'initial': matching,
|
||||||
|
'skipped': list(sorted(skipped)),
|
||||||
|
'new': list(sorted(set(matching) - skipped)),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def do_import(self, paths, user, options):
|
||||||
|
message = '{i}/{total} Importing {path}...'
|
||||||
if options['async']:
|
if options['async']:
|
||||||
message = 'Launching import for {}...'
|
message = '{i}/{total} Launching import for {path}...'
|
||||||
|
|
||||||
# we create an import batch binded to the user
|
# we create an import batch binded to the user
|
||||||
batch = user.imports.create(source='shell')
|
|
||||||
async = options['async']
|
async = options['async']
|
||||||
import_handler = tasks.import_job_run.delay if async else tasks.import_job_run
|
import_handler = tasks.import_job_run.delay if async else tasks.import_job_run
|
||||||
for path in matching:
|
batch = user.imports.create(source='shell')
|
||||||
|
total = len(paths)
|
||||||
|
errors = []
|
||||||
|
for i, path in list(enumerate(paths)):
|
||||||
try:
|
try:
|
||||||
self.stdout.write(message.format(path))
|
self.stdout.write(
|
||||||
|
message.format(path=path, i=i+1, total=len(paths)))
|
||||||
self.import_file(path, batch, import_handler, options)
|
self.import_file(path, batch, import_handler, options)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if options['exit_on_failure']:
|
if options['exit_on_failure']:
|
||||||
|
@ -122,18 +186,19 @@ class Command(BaseCommand):
|
||||||
m = 'Error while importing {}: {} {}'.format(
|
m = 'Error while importing {}: {} {}'.format(
|
||||||
path, e.__class__.__name__, e)
|
path, e.__class__.__name__, e)
|
||||||
self.stderr.write(m)
|
self.stderr.write(m)
|
||||||
return batch
|
errors.append((path, '{} {}'.format(e.__class__.__name__, e)))
|
||||||
|
return batch, errors
|
||||||
|
|
||||||
def import_file(self, path, batch, import_handler, options):
|
def import_file(self, path, batch, import_handler, options):
|
||||||
job = batch.jobs.create(
|
job = batch.jobs.create(
|
||||||
source='file://' + path,
|
source='file://' + path,
|
||||||
)
|
)
|
||||||
|
if not options['in_place']:
|
||||||
name = os.path.basename(path)
|
name = os.path.basename(path)
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
job.audio_file.save(name, File(f))
|
job.audio_file.save(name, File(f))
|
||||||
|
|
||||||
job.save()
|
job.save()
|
||||||
utils.on_commit(
|
import_handler(
|
||||||
import_handler,
|
|
||||||
import_job_id=job.pk,
|
import_job_id=job.pk,
|
||||||
use_acoustid=not options['no_acoustid'])
|
use_acoustid=not options['no_acoustid'])
|
||||||
|
|
|
@ -2,12 +2,14 @@ import acoustid
|
||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
from funkwhale_api.taskapp import celery
|
from funkwhale_api.taskapp import celery
|
||||||
from funkwhale_api.providers.acoustid import get_acoustid_client
|
from funkwhale_api.providers.acoustid import get_acoustid_client
|
||||||
from funkwhale_api.music import models, metadata
|
from funkwhale_api.music import models, metadata
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def import_track_data_from_path(path):
|
def import_track_data_from_path(path):
|
||||||
data = metadata.Metadata(path)
|
data = metadata.Metadata(path)
|
||||||
artist = models.Artist.objects.get_or_create(
|
artist = models.Artist.objects.get_or_create(
|
||||||
|
@ -45,6 +47,7 @@ def import_track_data_from_path(path):
|
||||||
def import_metadata_with_musicbrainz(path):
|
def import_metadata_with_musicbrainz(path):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@celery.app.task(name='audiofile.from_path')
|
@celery.app.task(name='audiofile.from_path')
|
||||||
def from_path(path):
|
def from_path(path):
|
||||||
acoustid_track_id = None
|
acoustid_track_id = None
|
||||||
|
|
|
@ -33,6 +33,7 @@ musicbrainzngs==0.6
|
||||||
youtube_dl>=2017.12.14
|
youtube_dl>=2017.12.14
|
||||||
djangorestframework>=3.7,<3.8
|
djangorestframework>=3.7,<3.8
|
||||||
djangorestframework-jwt>=1.11,<1.12
|
djangorestframework-jwt>=1.11,<1.12
|
||||||
|
oauth2client<4
|
||||||
google-api-python-client>=1.6,<1.7
|
google-api-python-client>=1.6,<1.7
|
||||||
arrow>=0.12,<0.13
|
arrow>=0.12,<0.13
|
||||||
persisting-theory>=0.2,<0.3
|
persisting-theory>=0.2,<0.3
|
||||||
|
|
|
@ -181,30 +181,6 @@ def test_can_import_whole_artist(
|
||||||
assert job.source == row['source']
|
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):
|
def test_user_can_create_an_empty_batch(superuser_api_client, factories):
|
||||||
url = reverse('api:v1:import-batches-list')
|
url = reverse('api:v1:import-batches-list')
|
||||||
response = superuser_api_client.post(url)
|
response = superuser_api_client.post(url)
|
||||||
|
|
|
@ -231,3 +231,15 @@ def test_import_batch_notifies_followers(
|
||||||
on_behalf_of=library_actor,
|
on_behalf_of=library_actor,
|
||||||
to=[f1.actor.url]
|
to=[f1.actor.url]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test__do_import_in_place_mbid(factories, tmpfile):
|
||||||
|
path = '/test.ogg'
|
||||||
|
job = factories['music.ImportJob'](
|
||||||
|
in_place=True, source='file:///test.ogg')
|
||||||
|
|
||||||
|
track = factories['music.Track'](mbid=job.mbid)
|
||||||
|
tf = tasks._do_import(job, use_acoustid=False)
|
||||||
|
|
||||||
|
assert bool(tf.audio_file) is False
|
||||||
|
assert tf.source == 'file:///test.ogg'
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
import uuid
|
||||||
|
|
||||||
from funkwhale_api.music import metadata
|
from funkwhale_api.music import metadata
|
||||||
|
|
||||||
|
@ -13,9 +14,9 @@ DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
('album', 'Peer Gynt Suite no. 1, op. 46'),
|
('album', 'Peer Gynt Suite no. 1, op. 46'),
|
||||||
('date', datetime.date(2012, 8, 15)),
|
('date', datetime.date(2012, 8, 15)),
|
||||||
('track_number', 1),
|
('track_number', 1),
|
||||||
('musicbrainz_albumid', 'a766da8b-8336-47aa-a3ee-371cc41ccc75'),
|
('musicbrainz_albumid', uuid.UUID('a766da8b-8336-47aa-a3ee-371cc41ccc75')),
|
||||||
('musicbrainz_recordingid', 'bd21ac48-46d8-4e78-925f-d9cc2a294656'),
|
('musicbrainz_recordingid', uuid.UUID('bd21ac48-46d8-4e78-925f-d9cc2a294656')),
|
||||||
('musicbrainz_artistid', '013c8e5b-d72a-4cd3-8dee-6c64d6125823'),
|
('musicbrainz_artistid', uuid.UUID('013c8e5b-d72a-4cd3-8dee-6c64d6125823')),
|
||||||
])
|
])
|
||||||
def test_can_get_metadata_from_ogg_file(field, value):
|
def test_can_get_metadata_from_ogg_file(field, value):
|
||||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||||
|
@ -30,9 +31,9 @@ def test_can_get_metadata_from_ogg_file(field, value):
|
||||||
('album', 'You Can\'t Stop Da Funk'),
|
('album', 'You Can\'t Stop Da Funk'),
|
||||||
('date', datetime.date(2006, 2, 7)),
|
('date', datetime.date(2006, 2, 7)),
|
||||||
('track_number', 1),
|
('track_number', 1),
|
||||||
('musicbrainz_albumid', 'ce40cdb1-a562-4fd8-a269-9269f98d4124'),
|
('musicbrainz_albumid', uuid.UUID('ce40cdb1-a562-4fd8-a269-9269f98d4124')),
|
||||||
('musicbrainz_recordingid', 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb'),
|
('musicbrainz_recordingid', uuid.UUID('f269d497-1cc0-4ae4-a0c4-157ec7d73fcb')),
|
||||||
('musicbrainz_artistid', '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13'),
|
('musicbrainz_artistid', uuid.UUID('9c6bddde-6228-4d9f-ad0d-03f6fcb19e13')),
|
||||||
])
|
])
|
||||||
def test_can_get_metadata_from_id3_mp3_file(field, value):
|
def test_can_get_metadata_from_id3_mp3_file(field, value):
|
||||||
path = os.path.join(DATA_DIR, 'test.mp3')
|
path = os.path.join(DATA_DIR, 'test.mp3')
|
||||||
|
|
|
@ -76,6 +76,31 @@ def test_can_serve_track_file_as_remote_library_deny_not_following(
|
||||||
assert response.status_code == 403
|
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(
|
def test_can_proxy_remote_track(
|
||||||
factories, settings, api_client, r_mock):
|
factories, settings, api_client, r_mock):
|
||||||
settings.PROTECT_AUDIO_FILES = False
|
settings.PROTECT_AUDIO_FILES = False
|
||||||
|
@ -93,6 +118,25 @@ def test_can_proxy_remote_track(
|
||||||
assert library_track.audio_file.read() == b'test'
|
assert library_track.audio_file.read() == b'test'
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_serve_in_place_imported_file(
|
||||||
|
factories, settings, api_client, r_mock):
|
||||||
|
settings.PROTECT_AUDIO_FILES = False
|
||||||
|
settings.MUSIC_DIRECTORY_SERVE_PATH = '/host/music'
|
||||||
|
settings.MUSIC_DIRECTORY_PATH = '/music'
|
||||||
|
settings.MUSIC_DIRECTORY_PATH = '/music'
|
||||||
|
track_file = factories['music.TrackFile'](
|
||||||
|
in_place=True,
|
||||||
|
source='file:///music/test.ogg')
|
||||||
|
|
||||||
|
response = api_client.get(track_file.path)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response['X-Accel-Redirect'] == '{}{}'.format(
|
||||||
|
settings.PROTECT_FILES_PATH,
|
||||||
|
'/music/host/music/test.ogg'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_can_create_import_from_federation_tracks(
|
def test_can_create_import_from_federation_tracks(
|
||||||
factories, superuser_api_client, mocker):
|
factories, superuser_api_client, mocker):
|
||||||
lts = factories['federation.LibraryTrack'].create_batch(size=5)
|
lts = factories['federation.LibraryTrack'].create_batch(size=5)
|
||||||
|
@ -109,3 +153,46 @@ def test_can_create_import_from_federation_tracks(
|
||||||
assert batch.jobs.count() == 5
|
assert batch.jobs.count() == 5
|
||||||
for i, job in enumerate(batch.jobs.all()):
|
for i, job in enumerate(batch.jobs.all()):
|
||||||
assert job.library_track == lts[i]
|
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 acoustid
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
|
@ -15,7 +17,8 @@ DATA_DIR = os.path.join(
|
||||||
|
|
||||||
|
|
||||||
def test_can_create_track_from_file_metadata(db, mocker):
|
def test_can_create_track_from_file_metadata(db, mocker):
|
||||||
mocker.patch('acoustid.match', side_effect=acoustid.WebServiceError('test'))
|
mocker.patch(
|
||||||
|
'acoustid.match', side_effect=acoustid.WebServiceError('test'))
|
||||||
metadata = {
|
metadata = {
|
||||||
'artist': ['Test artist'],
|
'artist': ['Test artist'],
|
||||||
'album': ['Test album'],
|
'album': ['Test album'],
|
||||||
|
@ -35,33 +38,49 @@ def test_can_create_track_from_file_metadata(db, mocker):
|
||||||
os.path.join(DATA_DIR, 'dummy_file.ogg'))
|
os.path.join(DATA_DIR, 'dummy_file.ogg'))
|
||||||
|
|
||||||
assert track.title == metadata['title'][0]
|
assert track.title == metadata['title'][0]
|
||||||
assert track.mbid == metadata['musicbrainz_trackid'][0]
|
assert track.mbid == uuid.UUID(metadata['musicbrainz_trackid'][0])
|
||||||
assert track.position == 4
|
assert track.position == 4
|
||||||
assert track.album.title == metadata['album'][0]
|
assert track.album.title == metadata['album'][0]
|
||||||
assert track.album.mbid == metadata['musicbrainz_albumid'][0]
|
assert track.album.mbid == uuid.UUID(metadata['musicbrainz_albumid'][0])
|
||||||
assert track.album.release_date == datetime.date(2012, 8, 15)
|
assert track.album.release_date == datetime.date(2012, 8, 15)
|
||||||
assert track.artist.name == metadata['artist'][0]
|
assert track.artist.name == metadata['artist'][0]
|
||||||
assert track.artist.mbid == metadata['musicbrainz_artistid'][0]
|
assert track.artist.mbid == uuid.UUID(metadata['musicbrainz_artistid'][0])
|
||||||
|
|
||||||
|
|
||||||
def test_management_command_requires_a_valid_username(factories, mocker):
|
def test_management_command_requires_a_valid_username(factories, mocker):
|
||||||
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
||||||
user = factories['users.User'](username='me')
|
user = factories['users.User'](username='me')
|
||||||
mocker.patch('funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import') # NOQA
|
mocker.patch(
|
||||||
|
'funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import', # noqa
|
||||||
|
return_value=(mocker.MagicMock(), []))
|
||||||
with pytest.raises(CommandError):
|
with pytest.raises(CommandError):
|
||||||
call_command('import_files', path, username='not_me', interactive=False)
|
call_command('import_files', path, username='not_me', interactive=False)
|
||||||
call_command('import_files', path, username='me', interactive=False)
|
call_command('import_files', path, username='me', interactive=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_place_import_only_from_music_dir(factories, settings):
|
||||||
|
user = factories['users.User'](username='me')
|
||||||
|
settings.MUSIC_DIRECTORY_PATH = '/nope'
|
||||||
|
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
||||||
|
with pytest.raises(CommandError):
|
||||||
|
call_command(
|
||||||
|
'import_files',
|
||||||
|
path,
|
||||||
|
in_place=True,
|
||||||
|
username='me',
|
||||||
|
interactive=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_import_files_creates_a_batch_and_job(factories, mocker):
|
def test_import_files_creates_a_batch_and_job(factories, mocker):
|
||||||
m = mocker.patch('funkwhale_api.common.utils.on_commit')
|
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||||
user = factories['users.User'](username='me')
|
user = factories['users.User'](username='me')
|
||||||
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
||||||
call_command(
|
call_command(
|
||||||
'import_files',
|
'import_files',
|
||||||
path,
|
path,
|
||||||
username='me',
|
username='me',
|
||||||
async=True,
|
async=False,
|
||||||
interactive=False)
|
interactive=False)
|
||||||
|
|
||||||
batch = user.imports.latest('id')
|
batch = user.imports.latest('id')
|
||||||
|
@ -76,45 +95,79 @@ def test_import_files_creates_a_batch_and_job(factories, mocker):
|
||||||
|
|
||||||
assert job.source == 'file://' + path
|
assert job.source == 'file://' + path
|
||||||
m.assert_called_once_with(
|
m.assert_called_once_with(
|
||||||
music_tasks.import_job_run.delay,
|
|
||||||
import_job_id=job.pk,
|
import_job_id=job.pk,
|
||||||
use_acoustid=True)
|
use_acoustid=True)
|
||||||
|
|
||||||
|
|
||||||
def test_import_files_skip_acoustid(factories, mocker):
|
def test_import_files_skip_acoustid(factories, mocker):
|
||||||
m = mocker.patch('funkwhale_api.common.utils.on_commit')
|
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||||
user = factories['users.User'](username='me')
|
user = factories['users.User'](username='me')
|
||||||
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
||||||
call_command(
|
call_command(
|
||||||
'import_files',
|
'import_files',
|
||||||
path,
|
path,
|
||||||
username='me',
|
username='me',
|
||||||
async=True,
|
async=False,
|
||||||
no_acoustid=True,
|
no_acoustid=True,
|
||||||
interactive=False)
|
interactive=False)
|
||||||
batch = user.imports.latest('id')
|
batch = user.imports.latest('id')
|
||||||
job = batch.jobs.first()
|
job = batch.jobs.first()
|
||||||
m.assert_called_once_with(
|
m.assert_called_once_with(
|
||||||
music_tasks.import_job_run.delay,
|
|
||||||
import_job_id=job.pk,
|
import_job_id=job.pk,
|
||||||
use_acoustid=False)
|
use_acoustid=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_files_skip_if_path_already_imported(factories, mocker):
|
||||||
|
user = factories['users.User'](username='me')
|
||||||
|
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
||||||
|
existing = factories['music.TrackFile'](
|
||||||
|
source='file://{}'.format(path))
|
||||||
|
|
||||||
|
call_command(
|
||||||
|
'import_files',
|
||||||
|
path,
|
||||||
|
username='me',
|
||||||
|
async=False,
|
||||||
|
no_acoustid=True,
|
||||||
|
interactive=False)
|
||||||
|
assert user.imports.count() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_import_files_works_with_utf8_file_name(factories, mocker):
|
def test_import_files_works_with_utf8_file_name(factories, mocker):
|
||||||
m = mocker.patch('funkwhale_api.common.utils.on_commit')
|
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||||
user = factories['users.User'](username='me')
|
user = factories['users.User'](username='me')
|
||||||
path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
|
path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
|
||||||
call_command(
|
call_command(
|
||||||
'import_files',
|
'import_files',
|
||||||
path,
|
path,
|
||||||
username='me',
|
username='me',
|
||||||
async=True,
|
async=False,
|
||||||
no_acoustid=True,
|
no_acoustid=True,
|
||||||
interactive=False)
|
interactive=False)
|
||||||
batch = user.imports.latest('id')
|
batch = user.imports.latest('id')
|
||||||
job = batch.jobs.first()
|
job = batch.jobs.first()
|
||||||
m.assert_called_once_with(
|
m.assert_called_once_with(
|
||||||
music_tasks.import_job_run.delay,
|
import_job_id=job.pk,
|
||||||
|
use_acoustid=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_files_in_place(factories, mocker, settings):
|
||||||
|
settings.MUSIC_DIRECTORY_PATH = DATA_DIR
|
||||||
|
m = mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||||
|
user = factories['users.User'](username='me')
|
||||||
|
path = os.path.join(DATA_DIR, 'utf8-éà◌.ogg')
|
||||||
|
call_command(
|
||||||
|
'import_files',
|
||||||
|
path,
|
||||||
|
username='me',
|
||||||
|
async=False,
|
||||||
|
in_place=True,
|
||||||
|
no_acoustid=True,
|
||||||
|
interactive=False)
|
||||||
|
batch = user.imports.latest('id')
|
||||||
|
job = batch.jobs.first()
|
||||||
|
assert bool(job.audio_file) is False
|
||||||
|
m.assert_called_once_with(
|
||||||
import_job_id=job.pk,
|
import_job_id=job.pk,
|
||||||
use_acoustid=False)
|
use_acoustid=False)
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,14 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
|
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
|
||||||
env_file: .env
|
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
|
command: celery -A funkwhale_api.taskapp worker -l INFO
|
||||||
links:
|
links:
|
||||||
- postgres
|
- 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
|
# If you're tweaking this file from the template, ensure you edit at least the
|
||||||
# following variables:
|
# following variables:
|
||||||
# - DJANGO_SECRET_KEY
|
# - DJANGO_SECRET_KEY
|
||||||
# - DJANGO_ALLOWED_HOSTS
|
# - DJANGO_ALLOWED_HOSTS
|
||||||
# - FUNKWHALE_URL
|
# - FUNKWHALE_URL
|
||||||
|
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
|
||||||
# Additionaly, on non-docker setup **only**, you'll also have to tweak/uncomment those variables:
|
|
||||||
# - DATABASE_URL
|
# - DATABASE_URL
|
||||||
# - CACHE_URL
|
# - CACHE_URL
|
||||||
# - STATIC_ROOT
|
# - STATIC_ROOT
|
||||||
# - MEDIA_ROOT
|
# - MEDIA_ROOT
|
||||||
#
|
#
|
||||||
# You **don't** need to update those variables on pure docker setups.
|
# You **don't** need to update those variables on pure docker setups.
|
||||||
|
#
|
||||||
|
# Additional options you may want to check:
|
||||||
|
# - MUSIC_DIRECTORY_PATH and MUSIC_DIRECTORY_SERVE_PATH if you plan to use
|
||||||
|
# in-place import
|
||||||
# Docker only
|
# Docker only
|
||||||
# -----------
|
# -----------
|
||||||
|
|
||||||
|
@ -19,7 +24,9 @@
|
||||||
# (it will be interpolated in docker-compose file)
|
# (it will be interpolated in docker-compose file)
|
||||||
# You can comment or ignore this if you're not using docker
|
# You can comment or ignore this if you're not using docker
|
||||||
FUNKWHALE_VERSION=latest
|
FUNKWHALE_VERSION=latest
|
||||||
|
MUSIC_DIRECTORY_PATH=/music
|
||||||
|
|
||||||
|
# End of Docker-only configuration
|
||||||
|
|
||||||
# General configuration
|
# General configuration
|
||||||
# ---------------------
|
# ---------------------
|
||||||
|
@ -34,6 +41,11 @@ FUNKWHALE_API_PORT=5000
|
||||||
# your instance
|
# your instance
|
||||||
FUNKWHALE_URL=https://yourdomain.funwhale
|
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
|
# API/Django configuration
|
||||||
|
|
||||||
# Database configuration
|
# Database configuration
|
||||||
|
@ -94,3 +106,9 @@ FEDERATION_ENABLED=True
|
||||||
# means anyone can subscribe to your library and import your file,
|
# means anyone can subscribe to your library and import your file,
|
||||||
# use with caution.
|
# use with caution.
|
||||||
FEDERATION_MUSIC_NEEDS_APPROVAL=True
|
FEDERATION_MUSIC_NEEDS_APPROVAL=True
|
||||||
|
|
||||||
|
# In-place import settings
|
||||||
|
# You can safely leave those settings uncommented if you don't plan to use
|
||||||
|
# in place imports.
|
||||||
|
# MUSIC_DIRECTORY_PATH=
|
||||||
|
# MUSIC_DIRECTORY_SERVE_PATH=
|
||||||
|
|
|
@ -8,6 +8,14 @@ User=funkwhale
|
||||||
# adapt this depending on the path of your funkwhale installation
|
# adapt this depending on the path of your funkwhale installation
|
||||||
WorkingDirectory=/srv/funkwhale/api
|
WorkingDirectory=/srv/funkwhale/api
|
||||||
EnvironmentFile=/srv/funkwhale/config/.env
|
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
|
ExecStart=/srv/funkwhale/virtualenv/bin/celery -A funkwhale_api.taskapp worker -l INFO
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
@ -84,6 +84,14 @@ server {
|
||||||
alias /srv/funkwhale/data/media;
|
alias /srv/funkwhale/data/media;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /_protected/music {
|
||||||
|
# this is an internal location that is used to serve
|
||||||
|
# audio files once correct permission / authentication
|
||||||
|
# has been checked on API side
|
||||||
|
internal;
|
||||||
|
alias /srv/funkwhale/data/music;
|
||||||
|
}
|
||||||
|
|
||||||
# Transcoding logic and caching
|
# Transcoding logic and caching
|
||||||
location = /transcode-auth {
|
location = /transcode-auth {
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
|
|
5
dev.yml
5
dev.yml
|
@ -65,7 +65,7 @@ services:
|
||||||
- "CACHE_URL=redis://redis:6379/0"
|
- "CACHE_URL=redis://redis:6379/0"
|
||||||
volumes:
|
volumes:
|
||||||
- ./api:/app
|
- ./api:/app
|
||||||
- ./data/music:/music
|
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
api:
|
api:
|
||||||
|
@ -78,7 +78,7 @@ services:
|
||||||
command: python /app/manage.py runserver 0.0.0.0:12081
|
command: python /app/manage.py runserver 0.0.0.0:12081
|
||||||
volumes:
|
volumes:
|
||||||
- ./api:/app
|
- ./api:/app
|
||||||
- ./data/music:/music
|
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
||||||
environment:
|
environment:
|
||||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
||||||
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
|
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
|
||||||
|
@ -107,6 +107,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
|
- ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
|
||||||
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
|
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
|
||||||
|
- "${MUSIC_DIRECTORY-./data/music}:/music:ro"
|
||||||
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
|
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
|
||||||
- ./api/funkwhale_api/media:/protected/media
|
- ./api/funkwhale_api/media:/protected/media
|
||||||
ports:
|
ports:
|
||||||
|
|
|
@ -42,6 +42,10 @@ http {
|
||||||
internal;
|
internal;
|
||||||
alias /protected/media;
|
alias /protected/media;
|
||||||
}
|
}
|
||||||
|
location /_protected/music {
|
||||||
|
internal;
|
||||||
|
alias /music;
|
||||||
|
}
|
||||||
location = /transcode-auth {
|
location = /transcode-auth {
|
||||||
# needed so we can authenticate transcode requests, but still
|
# needed so we can authenticate transcode requests, but still
|
||||||
# cache the result
|
# cache the result
|
||||||
|
|
|
@ -33,3 +33,44 @@ The URL should be ``/api/admin/dynamic_preferences/globalpreferencemodel/`` (pre
|
||||||
If you plan to use acoustid and external imports
|
If you plan to use acoustid and external imports
|
||||||
(e.g. with the youtube backends), you should edit the corresponding
|
(e.g. with the youtube backends), you should edit the corresponding
|
||||||
settings in this interface.
|
settings in this interface.
|
||||||
|
|
||||||
|
Configuration reference
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
.. _setting-MUSIC_DIRECTORY_PATH:
|
||||||
|
|
||||||
|
``MUSIC_DIRECTORY_PATH``
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Default: ``None``
|
||||||
|
|
||||||
|
The path on your server where Funwkhale can import files using :ref:`in-place import
|
||||||
|
<in-place-import>`. It must be readable by the webserver and funkwhale
|
||||||
|
api and worker processes.
|
||||||
|
|
||||||
|
On docker installations, we recommend you use the default of ``/music``
|
||||||
|
for this value. For non-docker installation, you can use any absolute path.
|
||||||
|
``/srv/funkwhale/data/music`` is a safe choice if you don't know what to use.
|
||||||
|
|
||||||
|
.. note:: This path should not include any trailing slash
|
||||||
|
|
||||||
|
.. _setting-MUSIC_DIRECTORY_SERVE_PATH:
|
||||||
|
|
||||||
|
``MUSIC_DIRECTORY_SERVE_PATH``
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Default: :ref:`setting-MUSIC_DIRECTORY_PATH`
|
||||||
|
|
||||||
|
When using Docker, the value of :ref:`MUSIC_DIRECTORY_PATH` in your containers
|
||||||
|
may differ from the real path on your host. Assuming you have the following directive
|
||||||
|
in your :file:`docker-compose.yml` file::
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /srv/funkwhale/data/music:/music:ro
|
||||||
|
|
||||||
|
Then, the value of :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH` should be
|
||||||
|
``/srv/funkwhale/data``. This must be readable by the webserver.
|
||||||
|
|
||||||
|
On non-docker setup, you don't need to configure this setting.
|
||||||
|
|
||||||
|
.. note:: This path should not include any trailing slash
|
||||||
|
|
|
@ -22,6 +22,13 @@ to the ``/music`` directory on the container:
|
||||||
|
|
||||||
docker-compose run --rm api python manage.py import_files "/music/**/*.ogg" --recursive --noinput
|
docker-compose run --rm api python manage.py import_files "/music/**/*.ogg" --recursive --noinput
|
||||||
|
|
||||||
|
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
|
For the best results, we recommand tagging your music collection through
|
||||||
`Picard <http://picard.musicbrainz.org/>`_ in order to have the best quality metadata.
|
`Picard <http://picard.musicbrainz.org/>`_ in order to have the best quality metadata.
|
||||||
|
|
||||||
|
@ -39,18 +46,39 @@ For the best results, we recommand tagging your music collection through
|
||||||
|
|
||||||
At the moment, only OGG/Vorbis and MP3 files with ID3 tags are supported
|
At the moment, only OGG/Vorbis and MP3 files with ID3 tags are supported
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
The --recursive flag will work only on Python 3.5+, which is the default
|
.. _in-place-import:
|
||||||
version When using Docker or Debian 9. If you use an older version of Python,
|
|
||||||
remove the --recursive flag and use more explicit import patterns instead::
|
|
||||||
|
|
||||||
# this will only import ogg files at the second level
|
In-place import
|
||||||
"/srv/funkwhale/data/music/*/*.ogg"
|
^^^^^^^^^^^^^^^
|
||||||
# this will only import ogg files in the fiven directory
|
|
||||||
"/srv/funkwhale/data/music/System-of-a-down/*.ogg"
|
|
||||||
|
|
||||||
|
By default, the CLI-importer will copy imported files to Funkwhale's internal
|
||||||
|
storage. This means importing a 1Gb library will result in the same amount
|
||||||
|
of space being used by Funkwhale.
|
||||||
|
|
||||||
|
While this behaviour has some benefits (easier backups and configuration),
|
||||||
|
it's not always the best choice, especially if you have a huge library
|
||||||
|
to import and don't want to double your disk usage.
|
||||||
|
|
||||||
|
The CLI importer supports an additional ``--in-place`` option that triggers the
|
||||||
|
following behaviour during import:
|
||||||
|
|
||||||
|
1. Imported files are not store in funkwhale anymore
|
||||||
|
2. Instead, Funkwhale will store the file path and use it to serve the music
|
||||||
|
|
||||||
|
Because those files are not managed by Funkwhale, we offer additional
|
||||||
|
configuration options to ensure the webserver can serve them properly:
|
||||||
|
|
||||||
|
- :ref:`setting-MUSIC_DIRECTORY_PATH`
|
||||||
|
- :ref:`setting-MUSIC_DIRECTORY_SERVE_PATH`
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
While in-place import is faster and less disk-space-hungry, it's also
|
||||||
|
more fragile: if, for some reason, you move or rename the source files,
|
||||||
|
Funkwhale will not be able to serve those files anymore.
|
||||||
|
|
||||||
|
Thus, be especially careful when you manipulate the source files.
|
||||||
|
|
||||||
Getting demo tracks
|
Getting demo tracks
|
||||||
^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
@ -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 worker to run asynchronouse tasks (such as music import)
|
||||||
- A celery scheduler to run recurrent tasks
|
- 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
|
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::
|
.. toctree::
|
||||||
:maxdepth: 1
|
: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"
|
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``.
|
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 vertical center aligned stripe segment">
|
||||||
<div class="ui text container">
|
<div class="ui text container">
|
||||||
<h1 class="ui huge header">
|
<h1 class="ui huge header">
|
||||||
<template v-if="instance.name.value">About {{ instance.name.value }}</template>
|
<template v-if="instance.name.value">{{ $t('About {%instance%}', { instance: instance.name.value }) }}</template>
|
||||||
<template v-else="instance.name.value">About this instance</template>
|
<template v-else="instance.name.value">{{ $t('About this instance') }}</template>
|
||||||
</h1>
|
</h1>
|
||||||
<stats></stats>
|
<stats></stats>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<p v-if="!instance.short_description.value && !instance.long_description.value">
|
<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
|
<div
|
||||||
v-if="instance.short_description.value"
|
v-if="instance.short_description.value"
|
||||||
class="ui middle aligned stackable text container">
|
class="ui middle aligned stackable text container">
|
||||||
|
|
|
@ -3,15 +3,15 @@
|
||||||
<div class="ui vertical center aligned stripe segment">
|
<div class="ui vertical center aligned stripe segment">
|
||||||
<div class="ui text container">
|
<div class="ui text container">
|
||||||
<h1 class="ui huge header">
|
<h1 class="ui huge header">
|
||||||
Welcome on Funkwhale
|
{{ $t('Welcome on Funkwhale') }}
|
||||||
</h1>
|
</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">
|
<router-link class="ui icon button" to="/about">
|
||||||
<i class="info icon"></i>
|
<i class="info icon"></i>
|
||||||
Learn more about this instance
|
{{ $t('Learn more about this instance') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link class="ui icon teal button" to="/library">
|
<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>
|
<i class="right arrow icon"></i>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,9 +22,9 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="eight wide left floated column">
|
<div class="eight wide left floated column">
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
Why funkwhale?
|
{{ $t('Why funkwhale?') }}
|
||||||
</h2>
|
</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>
|
||||||
<div class="four wide left floated column">
|
<div class="four wide left floated column">
|
||||||
<img class="ui medium image" src="../assets/logo/logo.png" />
|
<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 middle aligned stackable text container">
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
Unlimited music
|
{{ $t('Unlimited music') }}
|
||||||
</h2>
|
</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="ui list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="sound icon"></i>
|
<i class="sound icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="heart icon"></i>
|
<i class="heart icon"></i>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
Keep a track of your favorite songs
|
{{ $t('Keep a track of your favorite songs') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="list icon"></i>
|
<i class="list icon"></i>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
Playlists? We got them
|
{{ $t('Playlists? We got them') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,26 +62,28 @@
|
||||||
<div class="ui middle aligned stackable text container">
|
<div class="ui middle aligned stackable text container">
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
Clean library
|
{{ $t('Clean library') }}
|
||||||
</h2>
|
</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="ui list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="download icon"></i>
|
<i class="download icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="tag icon"></i>
|
<i class="tag icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="plus icon"></i>
|
<i class="plus icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -89,20 +91,20 @@
|
||||||
<div class="ui middle aligned stackable text container">
|
<div class="ui middle aligned stackable text container">
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
Easy to use
|
{{ $t('Easy to use') }}
|
||||||
</h2>
|
</h2>
|
||||||
<p>Funkwhale is dead simple to use.</p>
|
<p>{{ $t('Funkwhale is dead simple to use.') }}</p>
|
||||||
<div class="ui list">
|
<div class="ui list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="book icon"></i>
|
<i class="book icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="wizard icon"></i>
|
<i class="wizard icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -110,26 +112,26 @@
|
||||||
<div class="ui middle aligned stackable text container">
|
<div class="ui middle aligned stackable text container">
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
Your music, your way
|
{{ $t('Your music, your way') }}
|
||||||
</h2>
|
</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="ui list">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="smile icon"></i>
|
<i class="smile icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="protect icon"></i>
|
<i class="protect icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="users icon"></i>
|
<i class="users icon"></i>
|
||||||
<div class="content">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,13 +5,13 @@
|
||||||
<h1 class="ui huge header">
|
<h1 class="ui huge header">
|
||||||
<i class="warning icon"></i>
|
<i class="warning icon"></i>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<strike>Whale</strike> Page not found!
|
<strike>{{ $t('Whale') }}</strike> {{ $t('Page not found!') }}
|
||||||
</div>
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
<p>We're sorry, the page you asked for does not exists.</p>
|
<p>{{ $t('We\'re sorry, the page you asked for does not exists.') }}</p>
|
||||||
<p>Requested URL: <a :href="path">{{ path }}</a></p>
|
<i18next path="Requested URL: {%0%}"><a :href="path">{{ path }}</a></i18next>
|
||||||
<router-link class="ui icon button" to="/">
|
<router-link class="ui icon button" to="/">
|
||||||
Go to home page
|
{{ $t('Go to home page') }}
|
||||||
<i class="right arrow icon"></i>
|
<i class="right arrow icon"></i>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -18,12 +18,12 @@
|
||||||
<div class="ui compact fluid two item inverted menu">
|
<div class="ui compact fluid two item inverted menu">
|
||||||
<a class="active item" @click="selectedTab = 'library'" data-tab="library">Browse</a>
|
<a class="active item" @click="selectedTab = 'library'" data-tab="library">Browse</a>
|
||||||
<a class="item" @click="selectedTab = 'queue'" data-tab="queue">
|
<a class="item" @click="selectedTab = 'queue'" data-tab="queue">
|
||||||
Queue
|
{{ $t('Queue') }}
|
||||||
<template v-if="queue.tracks.length === 0">
|
<template v-if="queue.tracks.length === 0">
|
||||||
(empty)
|
{{ $t('(empty)') }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
({{ queue.currentIndex + 1}} of {{ queue.tracks.length }})
|
{{ $t('({%index%} of {%length%})', { index: queue.currentIndex + 1, length: queue.tracks.length }) }}
|
||||||
</template>
|
</template>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,37 +31,35 @@
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="ui bottom attached active tab" data-tab="library">
|
<div class="ui bottom attached active tab" data-tab="library">
|
||||||
<div class="ui inverted vertical fluid menu">
|
<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: '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> Logout</router-link>
|
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> {{ $t('Logout') }}</router-link>
|
||||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
|
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> {{ $t('Login') }}</router-link>
|
||||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
|
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
|
||||||
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
|
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i> {{ $t('Favorites') }}</router-link>
|
||||||
<a
|
<a
|
||||||
@click="$store.commit('playlists/chooseTrack', null)"
|
@click="$store.commit('playlists/chooseTrack', null)"
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="$store.state.auth.authenticated"
|
||||||
class="item">
|
class="item">
|
||||||
<i class="list icon"></i> Playlists
|
<i class="list icon"></i> {{ $t('Playlists') }}
|
||||||
</a>
|
</a>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="$store.state.auth.authenticated"
|
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
|
<router-link
|
||||||
class="item" v-if="$store.state.auth.availablePermissions['federation.manage']"
|
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>
|
</div>
|
||||||
|
|
||||||
<player></player>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="queue.previousQueue " class="ui black icon message">
|
<div v-if="queue.previousQueue " class="ui black icon message">
|
||||||
<i class="history icon"></i>
|
<i class="history icon"></i>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
Do you want to restore your previous queue?
|
{{ $t('Do you want to restore your previous queue?') }}
|
||||||
</div>
|
</div>
|
||||||
<p>{{ queue.previousQueue.tracks.length }} tracks</p>
|
<p>{{ $t('{%count%} tracks', { count: queue.previousQueue.tracks.length }) }}</p>
|
||||||
<div class="ui two buttons">
|
<div class="ui two buttons">
|
||||||
<div @click="queue.restore()" class="ui basic inverted green button">Yes</div>
|
<div @click="queue.restore()" class="ui basic inverted green button">{{ $t('Yes') }}</div>
|
||||||
<div @click="queue.removePrevious()" class="ui basic inverted red button">No</div>
|
<div @click="queue.removePrevious()" class="ui basic inverted red button">{{ $t('No') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -90,17 +88,17 @@
|
||||||
</draggable>
|
</draggable>
|
||||||
</table>
|
</table>
|
||||||
<div v-if="$store.state.radios.running" class="ui black message">
|
<div v-if="$store.state.radios.running" class="ui black message">
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="header">
|
<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>
|
</div>
|
||||||
<p>New tracks will be appended here automatically.</p>
|
<p>{{ $t('New tracks will be appended here automatically.') }}</p>
|
||||||
<div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">Stop radio</div>
|
<div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">{{ $t('Stop radio') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<player @next="scrollToCurrent" @previous="scrollToCurrent"></player>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -143,8 +141,9 @@ export default {
|
||||||
...mapActions({
|
...mapActions({
|
||||||
cleanTrack: 'queue/cleanTrack'
|
cleanTrack: 'queue/cleanTrack'
|
||||||
}),
|
}),
|
||||||
reorder: function (oldValue, newValue) {
|
reorder: function (event) {
|
||||||
this.$store.commit('queue/reorder', {oldValue, newValue})
|
this.$store.commit('queue/reorder', {
|
||||||
|
oldIndex: event.oldIndex, newIndex: event.newIndex})
|
||||||
},
|
},
|
||||||
scrollToCurrent () {
|
scrollToCurrent () {
|
||||||
let current = $(this.$el).find('[data-tab="queue"] .active')[0]
|
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
|
// for half the height of the containers display area
|
||||||
var scrollBack = (container.scrollHeight - container.scrollTop <= container.clientHeight) ? 0 : container.clientHeight / 2
|
var scrollBack = (container.scrollHeight - container.scrollTop <= container.clientHeight) ? 0 : container.clientHeight / 2
|
||||||
container.scrollTop = container.scrollTop - scrollBack
|
container.scrollTop = container.scrollTop - scrollBack
|
||||||
console.log(container.scrollHeight - container.scrollTop, container.clientHeight)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -239,9 +237,6 @@ $sidebar-color: #3D3E3F;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@include media(">tablet") {
|
|
||||||
height: 0px;
|
|
||||||
}
|
|
||||||
@include media("<desktop") {
|
@include media("<desktop") {
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<i18next path="{%0%} favorited a track {%1%}">
|
<i18next path="{%0%} favorited a track">
|
||||||
<slot name="user"></slot>
|
<username class="user" :username="event.actor.local_id" />
|
||||||
<slot name="date"></slot>
|
|
||||||
</i18next>
|
</i18next>
|
||||||
|
<human-date class="date" :date="event.published" />
|
||||||
</div>
|
</div>
|
||||||
<div class="extra text">
|
<div class="extra text">
|
||||||
<router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
|
<router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
|
||||||
|
|
|
@ -5,16 +5,16 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<i18next path="{%0%} listened to a track {%1%}">
|
<i18next path="{%0%} listened to a track">
|
||||||
<slot name="user"></slot>
|
<username class="user" :username="event.actor.local_id" />
|
||||||
<slot name="date"></slot>
|
|
||||||
</i18next>
|
</i18next>
|
||||||
|
<human-date class="date" :date="event.published" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="extra text">
|
<div class="extra text">
|
||||||
<router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
|
<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">
|
<i18next path="from album {%0%}, by {%1%}" v-if="event.object.album">
|
||||||
{{ event.object.album }}
|
{{ event.object.album }}<em>{{ event.object.artist }}</em>
|
||||||
<em>{{ event.object.artist }}</em>
|
|
||||||
</i18next>
|
</i18next>
|
||||||
<i18next path=", by {%0%}" v-else>
|
<i18next path=", by {%0%}" v-else>
|
||||||
<em>{{ event.object.artist }}</em>
|
<em>{{ event.object.artist }}</em>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<audio-track
|
<audio-track
|
||||||
ref="currentAudio"
|
ref="currentAudio"
|
||||||
v-if="renderAudio && currentTrack"
|
v-if="renderAudio && currentTrack"
|
||||||
:key="(currentIndex, currentTrack.id)"
|
:key="currentTrack.id"
|
||||||
:is-current="true"
|
:is-current="true"
|
||||||
:start-time="$store.state.player.currentTime"
|
:start-time="$store.state.player.currentTime"
|
||||||
:autoplay="$store.state.player.playing"
|
:autoplay="$store.state.player.playing"
|
||||||
|
@ -173,11 +173,21 @@ export default {
|
||||||
...mapActions({
|
...mapActions({
|
||||||
togglePlay: 'player/togglePlay',
|
togglePlay: 'player/togglePlay',
|
||||||
clean: 'queue/clean',
|
clean: 'queue/clean',
|
||||||
next: 'queue/next',
|
|
||||||
previous: 'queue/previous',
|
|
||||||
shuffle: 'queue/shuffle',
|
shuffle: 'queue/shuffle',
|
||||||
updateProgress: 'player/updateProgress'
|
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) {
|
touchProgress (e) {
|
||||||
let time
|
let time
|
||||||
let target = this.$refs.progress
|
let target = this.$refs.progress
|
||||||
|
|
|
@ -73,7 +73,10 @@ export default {
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
errored: function () {
|
errored: function () {
|
||||||
this.$store.dispatch('player/trackErrored')
|
let self = this
|
||||||
|
setTimeout(
|
||||||
|
() => { self.$store.dispatch('player/trackErrored') }
|
||||||
|
, 1000)
|
||||||
},
|
},
|
||||||
sourceErrored: function () {
|
sourceErrored: function () {
|
||||||
this.sourceErrors += 1
|
this.sourceErrors += 1
|
||||||
|
@ -83,9 +86,15 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateDuration: function (e) {
|
updateDuration: function (e) {
|
||||||
|
if (!this.$refs.audio) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.$store.commit('player/duration', this.$refs.audio.duration)
|
this.$store.commit('player/duration', this.$refs.audio.duration)
|
||||||
},
|
},
|
||||||
loaded: function () {
|
loaded: function () {
|
||||||
|
if (!this.$refs.audio) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.$refs.audio.volume = this.volume
|
this.$refs.audio.volume = this.volume
|
||||||
this.$store.commit('player/resetErrorCount')
|
this.$store.commit('player/resetErrorCount')
|
||||||
if (this.isCurrent) {
|
if (this.isCurrent) {
|
||||||
|
|
|
@ -11,10 +11,7 @@
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ artist.name }}
|
{{ artist.name }}
|
||||||
<div class="sub header">
|
<div class="sub header">
|
||||||
<i18next path="{%0%} tracks in {%1%} albums">
|
{{ $t('{% track_count %} tracks in {% album_count %} albums', {track_count: totalTracks, album_count: albums.length})}}
|
||||||
{{ totalTracks }}
|
|
||||||
{{ albums.length }}
|
|
||||||
</i18next>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
@ -4,31 +4,80 @@
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="batch" class="ui vertical stripe segment">
|
<div v-if="batch" class="ui vertical stripe segment">
|
||||||
<div :class="
|
<table class="ui very basic table">
|
||||||
['ui',
|
<tbody>
|
||||||
{'active': batch.status === 'pending'},
|
<tr>
|
||||||
{'warning': batch.status === 'pending'},
|
<td>
|
||||||
{'error': batch.status === 'errored'},
|
<strong>{{ $t('Import batch') }}</strong>
|
||||||
{'success': batch.status === 'finished'},
|
</td>
|
||||||
'progress']">
|
<td>
|
||||||
<div class="bar" :style="progressBarStyle">
|
#{{ batch.id }}
|
||||||
<div class="progress"></div>
|
</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>
|
||||||
<div v-if="batch.status === 'pending'" class="label">Importing {{ batch.jobs.length }} tracks...</div>
|
<div class="ui field">
|
||||||
<div v-if="batch.status === 'finished'" class="label">Imported {{ batch.jobs.length }} tracks!</div>
|
<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>
|
||||||
<table class="ui unstackable table">
|
</div>
|
||||||
|
</div>
|
||||||
|
<table v-if="jobResult" class="ui unstackable table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<i18next tag="th" path="Job ID"/>
|
<th>{{ $t('Job ID') }}</th>
|
||||||
<i18next tag="th" path="Recording MusicBrainz ID"/>
|
<th>{{ $t('Recording MusicBrainz ID') }}</th>
|
||||||
<i18next tag="th" path="Source"/>
|
<th>{{ $t('Source') }}</th>
|
||||||
<i18next tag="th" path="Status"/>
|
<th>{{ $t('Status') }}</th>
|
||||||
<i18next tag="th" path="Track"/>
|
<th>{{ $t('Track') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="job in batch.jobs">
|
<tr v-for="job in jobResult.results">
|
||||||
<td>{{ job.id }}</th>
|
<td>{{ job.id }}</th>
|
||||||
<td>
|
<td>
|
||||||
<a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
|
<a :href="'https://www.musicbrainz.org/recording/' + job.mbid" target="_blank">{{ job.mbid }}</a>
|
||||||
|
@ -45,29 +94,64 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</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>
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import _ from 'lodash'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
|
import Pagination from '@/components/Pagination'
|
||||||
const FETCH_URL = 'import-batches/'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['id'],
|
props: ['id'],
|
||||||
|
components: {
|
||||||
|
Pagination
|
||||||
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
batch: null,
|
batch: null,
|
||||||
timeout: null
|
stats: null,
|
||||||
|
jobResult: null,
|
||||||
|
timeout: null,
|
||||||
|
jobFilters: {
|
||||||
|
status: null,
|
||||||
|
source: null,
|
||||||
|
search: '',
|
||||||
|
paginateBy: 25,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.fetchData()
|
let self = this
|
||||||
|
this.fetchData().then(() => {
|
||||||
|
self.fetchJobs()
|
||||||
|
self.fetchStats()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
destroyed () {
|
destroyed () {
|
||||||
if (this.timeout) {
|
if (this.timeout) {
|
||||||
|
@ -78,9 +162,9 @@ export default {
|
||||||
fetchData () {
|
fetchData () {
|
||||||
var self = this
|
var self = this
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
let url = FETCH_URL + this.id + '/'
|
let url = 'import-batches/' + this.id + '/'
|
||||||
logger.default.debug('Fetching batch "' + 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.batch = response.data
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
if (self.batch.status === 'pending') {
|
if (self.batch.status === 'pending') {
|
||||||
|
@ -90,21 +174,58 @@ export default {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
fetchStats () {
|
||||||
progress () {
|
var self = this
|
||||||
return this.batch.jobs.filter(j => {
|
let url = 'import-jobs/stats/'
|
||||||
return j.status !== 'pending'
|
axios.get(url, {params: {batch: self.id}}).then((response) => {
|
||||||
}).length * 100 / this.batch.jobs.length
|
let old = self.stats
|
||||||
},
|
self.stats = response.data
|
||||||
progressBarStyle () {
|
self.isLoading = false
|
||||||
return 'width: ' + parseInt(this.progress) + '%'
|
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: {
|
watch: {
|
||||||
id () {
|
id () {
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
|
},
|
||||||
|
jobFilters: {
|
||||||
|
handler () {
|
||||||
|
this.fetchJobs()
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,40 +2,82 @@
|
||||||
<div v-title="'Import Batches'">
|
<div v-title="'Import Batches'">
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
<div v-if="isLoading" :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
<button
|
<div class="ui inline form">
|
||||||
class="ui left floated labeled icon button"
|
<div class="fields">
|
||||||
@click="fetchData(previousLink)"
|
<div class="ui field">
|
||||||
:disabled="!previousLink"><i class="left arrow icon"></i><i18next path="Previous"/></button>
|
<label>{{ $t('Search') }}</label>
|
||||||
<button
|
<input type="text" v-model="filters.search" placeholder="Search by submitter, source..." />
|
||||||
class="ui right floated right labeled icon button"
|
</div>
|
||||||
@click="fetchData(nextLink)"
|
<div class="ui field">
|
||||||
:disabled="!nextLink"><i18next path="Next"/><i class="right arrow icon"></i></button>
|
<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>
|
||||||
<div class="ui hidden clearing divider"></div>
|
<table v-if="result && result.results.length > 0" class="ui unstackable table">
|
||||||
<table v-if="results.length > 0" class="ui unstackable table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<i18next tag="th" path="ID"/>
|
<th>{{ $t('ID') }}</th>
|
||||||
<i18next tag="th" path="Launch date"/>
|
<th>{{ $t('Launch date') }}</th>
|
||||||
<i18next tag="th" path="Jobs"/>
|
<th>{{ $t('Jobs') }}</th>
|
||||||
<i18next tag="th" path="Status"/>
|
<th>{{ $t('Status') }}</th>
|
||||||
|
<th>{{ $t('Source') }}</th>
|
||||||
|
<th>{{ $t('Submitted by') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="result in results">
|
<tr v-for="obj in result.results">
|
||||||
<td>{{ result.id }}</th>
|
<td>{{ obj.id }}</th>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'library.import.batches.detail', params: {id: result.id }}">
|
<router-link :to="{name: 'library.import.batches.detail', params: {id: obj.id }}">
|
||||||
{{ result.creation_date }}
|
<human-date :date="obj.creation_date"></human-date>
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ result.jobs.length }}</td>
|
<td>{{ obj.job_count }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
:class="['ui', {'yellow': result.status === 'pending'}, {'red': result.status === 'errored'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span>
|
:class="['ui', {'yellow': obj.status === 'pending'}, {'red': obj.status === 'errored'}, {'green': obj.status === 'finished'}, 'label']">{{ obj.status }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ obj.source }}</td>
|
||||||
|
<td><template v-if="obj.submitted_by">{{ obj.submitted_by.username }}</template></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,34 +86,60 @@
|
||||||
<script>
|
<script>
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
|
import Pagination from '@/components/Pagination'
|
||||||
const BATCHES_URL = 'import-batches/'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {},
|
components: {
|
||||||
|
Pagination
|
||||||
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
results: [],
|
result: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
nextLink: null,
|
filters: {
|
||||||
previousLink: null
|
status: null,
|
||||||
|
source: null,
|
||||||
|
search: '',
|
||||||
|
paginateBy: 25,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.fetchData(BATCHES_URL)
|
this.fetchData()
|
||||||
},
|
},
|
||||||
methods: {
|
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
|
var self = this
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
logger.default.time('Loading import batches')
|
logger.default.time('Loading import batches')
|
||||||
axios.get(url, {}).then((response) => {
|
axios.get('import-batches/', {params}).then((response) => {
|
||||||
self.results = response.data.results
|
self.result = response.data
|
||||||
self.nextLink = response.data.next
|
|
||||||
self.previousLink = response.data.previous
|
|
||||||
logger.default.timeEnd('Loading import batches')
|
logger.default.timeEnd('Loading import batches')
|
||||||
self.isLoading = false
|
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'] }}
|
{{ group['first-release-date'] }}
|
||||||
</td>
|
</td>
|
||||||
<td colspan="3">
|
<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 }}
|
{{ group.title }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
{{ track.position }}
|
{{ track.position }}
|
||||||
</td>
|
</td>
|
||||||
<td colspan="3">
|
<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 }}
|
{{ track.recording.title }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ui fluid search">
|
<div class="ui fluid search">
|
||||||
<div class="ui icon input">
|
<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>
|
<i class="search icon"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="results"></div>
|
<div class="results"></div>
|
||||||
|
@ -32,21 +32,7 @@ export default {
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
currentType: this.mbType || 'artist',
|
currentType: this.mbType || 'artist',
|
||||||
currentId: this.mbId || '',
|
currentId: this.mbId || ''
|
||||||
types: [
|
|
||||||
{
|
|
||||||
value: 'artist',
|
|
||||||
label: 'Artist'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'release',
|
|
||||||
label: 'Album'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'recording',
|
|
||||||
label: 'Track'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -132,6 +118,22 @@ export default {
|
||||||
},
|
},
|
||||||
searchUrl: function () {
|
searchUrl: function () {
|
||||||
return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}'
|
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: {
|
watch: {
|
||||||
|
|
|
@ -10,13 +10,16 @@
|
||||||
<i class="user icon"></i> {{ playlist.user.username }}
|
<i class="user icon"></i> {{ playlist.user.username }}
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<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>
|
</div>
|
||||||
<div class="extra content">
|
<div class="extra content">
|
||||||
<span>
|
<span>
|
||||||
<i class="sound icon"></i>
|
<i class="sound icon"></i>
|
||||||
{{ playlist.tracks_count }} tracks
|
{{ $t('{%count%} tracks', { count: playlist.tracks_count }) }}
|
||||||
</span>
|
</span>
|
||||||
<play-button class="mini basic orange right floated" :playlist="playlist">Play all</play-button>
|
<play-button class="mini basic orange right floated" :playlist="playlist">Play all</play-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,16 +2,16 @@
|
||||||
<div class="ui text container">
|
<div class="ui text container">
|
||||||
<playlist-form @updated="$emit('playlist-updated', $event)" :title="false" :playlist="playlist"></playlist-form>
|
<playlist-form @updated="$emit('playlist-updated', $event)" :title="false" :playlist="playlist"></playlist-form>
|
||||||
<h3 class="ui top attached header">
|
<h3 class="ui top attached header">
|
||||||
Playlist editor
|
{{ $t('Playlist editor') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
<template v-if="status === 'loading'">
|
<template v-if="status === 'loading'">
|
||||||
<div class="ui active tiny inline loader"></div>
|
<div class="ui active tiny inline loader"></div>
|
||||||
Syncing changes to server...
|
{{ $t('Syncing changes to server...') }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="status === 'errored'">
|
<template v-else-if="status === 'errored'">
|
||||||
<i class="red close icon"></i>
|
<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">
|
<div v-if="errors.length > 0" class="ui negative message">
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li v-for="error in errors">{{ error }}</li>
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="status === 'saved'">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui bottom attached segment">
|
<div class="ui bottom attached segment">
|
||||||
|
@ -28,13 +28,15 @@
|
||||||
:disabled="queueTracks.length === 0"
|
:disabled="queueTracks.length === 0"
|
||||||
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
|
:class="['ui', {disabled: queueTracks.length === 0}, 'labeled', 'icon', 'button']"
|
||||||
title="Copy tracks from current queue to playlist">
|
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">
|
<dangerous-button :disabled="plts.length === 0" class="labeled right floated icon" color='yellow' :action="clearPlaylist">
|
||||||
<i class="eraser icon"></i> Clear playlist
|
<i class="eraser icon"></i> {{ $t('Clear playlist') }}
|
||||||
<p slot="modal-header">Do you want to clear the playlist "{{ playlist.name }}"?</p>
|
<p slot="modal-header">{{ $t('Do you want to clear the playlist "{%name%}"?', { name: playlist.name }) }}</p>
|
||||||
<p slot="modal-content">This will remove all tracks from this playlist and cannot be undone.</p>
|
<p slot="modal-content">{{ $t('This will remove all tracks from this playlist and cannot be undone.') }}</p>
|
||||||
<p slot="modal-confirm">Clear playlist</p>
|
<p slot="modal-confirm">{{ $t('Clear playlist') }}</p>
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<template v-if="plts.length > 0">
|
<template v-if="plts.length > 0">
|
||||||
|
|
|
@ -1,29 +1,29 @@
|
||||||
<template>
|
<template>
|
||||||
<form class="ui form" @submit.prevent="submit()">
|
<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 v-if="success" class="ui positive message">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<template v-if="playlist">
|
<template v-if="playlist">
|
||||||
Playlist updated
|
{{ $t('Playlist updated') }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
Playlist created
|
{{ $t('Playlist created') }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="errors.length > 0" class="ui negative message">
|
<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">
|
<ul class="list">
|
||||||
<li v-for="error in errors">{{ error }}</li>
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="three fields">
|
<div class="three fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Playlist name</label>
|
<label>{{ $t('Playlist name') }}</label>
|
||||||
<input v-model="name" required type="text" placeholder="My awesome playlist" />
|
<input v-model="name" required type="text" placeholder="My awesome playlist" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Playlist visibility</label>
|
<label>{{ $t('Playlist visibility') }}</label>
|
||||||
<select class="ui dropdown" v-model="privacyLevel">
|
<select class="ui dropdown" v-model="privacyLevel">
|
||||||
<option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option>
|
<option :value="c.value" v-for="c in privacyLevelChoices">{{ c.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
@ -31,8 +31,8 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label> </label>
|
<label> </label>
|
||||||
<button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit">
|
<button :class="['ui', 'fluid', {'loading': isLoading}, 'button']" type="submit">
|
||||||
<template v-if="playlist">Update playlist</template>
|
<template v-if="playlist">{{ $t('Update playlist') }}</template>
|
||||||
<template v-else>Create playlist</template>
|
<template v-else>{{ $t('Create playlist') }}</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -57,21 +57,7 @@ export default {
|
||||||
let d = {
|
let d = {
|
||||||
errors: [],
|
errors: [],
|
||||||
success: false,
|
success: false,
|
||||||
isLoading: false,
|
isLoading: false
|
||||||
privacyLevelChoices: [
|
|
||||||
{
|
|
||||||
value: 'me',
|
|
||||||
label: 'Nobody except me'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'instance',
|
|
||||||
label: 'Everyone on this instance'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'everyone',
|
|
||||||
label: 'Everyone'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (this.playlist) {
|
if (this.playlist) {
|
||||||
d.name = this.playlist.name
|
d.name = this.playlist.name
|
||||||
|
@ -82,6 +68,24 @@ export default {
|
||||||
}
|
}
|
||||||
return d
|
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: {
|
methods: {
|
||||||
submit () {
|
submit () {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<modal @update:show="update" :show="$store.state.playlists.showModal">
|
<modal @update:show="update" :show="$store.state.playlists.showModal">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
Manage playlists
|
{{ $t('Manage playlists') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="scrolling content">
|
<div class="scrolling content">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<template v-if="track">
|
<template v-if="track">
|
||||||
<h4 class="ui header">Current track</h4>
|
<h4 class="ui header">{{ $t('Current track') }}</h4>
|
||||||
<div>
|
<div>
|
||||||
"{{ track.title }}" by {{ track.artist.name }}
|
{{ $t('"{%title%}" by {%artist%}', { title: track.title, artist: track.artist.name }) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="ui divider"></div>
|
<div class="ui divider"></div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -16,20 +16,20 @@
|
||||||
<playlist-form></playlist-form>
|
<playlist-form></playlist-form>
|
||||||
<div class="ui divider"></div>
|
<div class="ui divider"></div>
|
||||||
<div v-if="errors.length > 0" class="ui negative message">
|
<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">
|
<ul class="list">
|
||||||
<li v-for="error in errors">{{ error }}</li>
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="ui header">Available playlists</h4>
|
<h4 class="ui header">{{ $t('Available playlists') }}</h4>
|
||||||
<table class="ui unstackable very basic table">
|
<table class="ui unstackable very basic table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>Name</th>
|
<th>{{ $t('Name') }}</th>
|
||||||
<th class="sorted descending">Last modification</th>
|
<th class="sorted descending">{{ $t('Last modification') }}</th>
|
||||||
<th>Tracks</th>
|
<th>{{ $t('Tracks') }}</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -48,9 +48,9 @@
|
||||||
<div
|
<div
|
||||||
v-if="track"
|
v-if="track"
|
||||||
class="ui green icon basic small right floated button"
|
class="ui green icon basic small right floated button"
|
||||||
title="Add to this playlist"
|
:title="$t('Add to this playlist')"
|
||||||
@click="addToPlaylist(playlist.id)">
|
@click="addToPlaylist(playlist.id)">
|
||||||
<i class="plus icon"></i> Add track
|
<i class="plus icon"></i> {{ $t('Add track') }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -59,7 +59,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="ui cancel button">Cancel</div>
|
<div class="ui cancel button">{{ $t('Cancel') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</modal>
|
</modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -4,13 +4,13 @@
|
||||||
v-if="button"
|
v-if="button"
|
||||||
:class="['ui', 'button']">
|
:class="['ui', 'button']">
|
||||||
<i class="list icon"></i>
|
<i class="list icon"></i>
|
||||||
Add to playlist...
|
{{ $t('Add to playlist...') }}
|
||||||
</button>
|
</button>
|
||||||
<i
|
<i
|
||||||
v-else
|
v-else
|
||||||
@click="$store.commit('playlists/chooseTrack', track)"
|
@click="$store.commit('playlists/chooseTrack', track)"
|
||||||
:class="['playlist-icon', 'list', 'link', 'icon']"
|
:class="['playlist-icon', 'list', 'link', 'icon']"
|
||||||
title="Add to playlist...">
|
:title="$t('Add to playlist...')">
|
||||||
</i>
|
</i>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<button @click="toggleRadio" :class="['ui', 'blue', {'inverted': running}, 'button']">
|
<button @click="toggleRadio" :class="['ui', 'blue', {'inverted': running}, 'button']">
|
||||||
<i class="ui feed icon"></i>
|
<i class="ui feed icon"></i>
|
||||||
<template v-if="running">Stop</template>
|
<template v-if="running">{{ $t('Stop') }}</template>
|
||||||
<template v-else>Start</template>
|
<template v-else>{{ $t('Start') }}</template>
|
||||||
radio
|
radio
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
class="ui basic yellow button"
|
class="ui basic yellow button"
|
||||||
v-if="$store.state.auth.authenticated && type === 'custom' && customRadio.user === $store.state.auth.profile.id"
|
v-if="$store.state.auth.authenticated && type === 'custom' && customRadio.user === $store.state.auth.profile.id"
|
||||||
:to="{name: 'library.radios.edit', params: {id: customRadioId }}">
|
:to="{name: 'library.radios.edit', params: {id: customRadioId }}">
|
||||||
Edit...
|
{{ $t('Edit...') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId"></radio-button>
|
<radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId"></radio-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
<button
|
<button
|
||||||
@click="createImport"
|
@click="createImport"
|
||||||
v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,30 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<form v-if="!over" class="ui form" @submit.prevent="submit">
|
<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">
|
<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">
|
<input v-model="currentArtistName" placeholder="The Beatles, Mickael Jackson…" required maxlength="200">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Albums</label>
|
<label>{{ $t('Albums') }}</label>
|
||||||
<p>Leave this field empty if you're requesting the whole discography.</p>
|
<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">
|
<input v-model="currentAlbums" placeholder="The White Album, Thriller…" maxlength="2000">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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>
|
<textarea v-model="currentComment" rows="3" placeholder="Use this comment box to add details to your request if needed" maxlength="2000"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="ui submit button" type="submit">Submit</button>
|
<button class="ui submit button" type="submit">{{ $t('Submit') }}</button>
|
||||||
</form>
|
</form>
|
||||||
<div v-else class="ui success message">
|
<div v-else class="ui success message">
|
||||||
<div class="header">Request submitted!</div>
|
<div class="header">Request submitted!</div>
|
||||||
<p>We've received your request, you'll get some groove soon ;)</p>
|
<p>{{ $t('We\'ve received your request, you\'ll get some groove soon ;)') }}</p>
|
||||||
<button @click="reset" class="ui button">Submit another request</button>
|
<button @click="reset" class="ui button">{{ $t('Submit another request') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="requests.length > 0">
|
<div v-if="requests.length > 0">
|
||||||
<div class="ui divider"></div>
|
<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 class="ui list">
|
||||||
<div v-for="request in requests" class="item">
|
<div v-for="request in requests" class="item">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-title="'Import Requests'">
|
<div v-title="'Import Requests'">
|
||||||
<div class="ui vertical stripe segment">
|
<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="['ui', {'loading': isLoading}, 'form']">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Search</label>
|
<label>{{ $t('Search') }}</label>
|
||||||
<input type="text" v-model="query" placeholder="Enter an artist name, a username..."/>
|
<input type="text" v-model="query" placeholder="Enter an artist name, a username..."/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ordering</label>
|
<label>{{ $t('Ordering') }}</label>
|
||||||
<select class="ui dropdown" v-model="ordering">
|
<select class="ui dropdown" v-model="ordering">
|
||||||
<option v-for="option in orderingOptions" :value="option[0]">
|
<option v-for="option in orderingOptions" :value="option[0]">
|
||||||
{{ option[1] }}
|
{{ option[1] }}
|
||||||
|
@ -17,14 +17,14 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ordering direction</label>
|
<label>{{ $t('Ordering direction') }}</label>
|
||||||
<select class="ui dropdown" v-model="orderingDirection">
|
<select class="ui dropdown" v-model="orderingDirection">
|
||||||
<option value="">Ascending</option>
|
<option value="">Ascending</option>
|
||||||
<option value="-">Descending</option>
|
<option value="-">Descending</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Results per page</label>
|
<label>{{ $t('Results per page') }}</label>
|
||||||
<select class="ui dropdown" v-model="paginateBy">
|
<select class="ui dropdown" v-model="paginateBy">
|
||||||
<option :value="parseInt(12)">12</option>
|
<option :value="parseInt(12)">12</option>
|
||||||
<option :value="parseInt(25)">25</option>
|
<option :value="parseInt(25)">25</option>
|
||||||
|
@ -96,12 +96,7 @@ export default {
|
||||||
query: this.defaultQuery,
|
query: this.defaultQuery,
|
||||||
paginateBy: parseInt(this.defaultPaginateBy || 12),
|
paginateBy: parseInt(this.defaultPaginateBy || 12),
|
||||||
orderingDirection: defaultOrdering.direction,
|
orderingDirection: defaultOrdering.direction,
|
||||||
ordering: defaultOrdering.field,
|
ordering: defaultOrdering.field
|
||||||
orderingOptions: [
|
|
||||||
['creation_date', 'Creation date'],
|
|
||||||
['artist_name', 'Artist name'],
|
|
||||||
['user__username', 'User']
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
|
@ -141,6 +136,15 @@ export default {
|
||||||
this.page = page
|
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: {
|
watch: {
|
||||||
page () {
|
page () {
|
||||||
this.updateQueryString()
|
this.updateQueryString()
|
||||||
|
|
|
@ -35,14 +35,14 @@ Vue.use(VueMasonryPlugin)
|
||||||
Vue.use(VueLazyload)
|
Vue.use(VueLazyload)
|
||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
Vue.directive('title', {
|
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' }
|
updated: (el, binding) => { document.title = binding.value + ' - Funkwhale' }
|
||||||
})
|
})
|
||||||
|
|
||||||
axios.defaults.baseURL = config.API_URL
|
axios.defaults.baseURL = config.API_URL
|
||||||
axios.interceptors.request.use(function (config) {
|
axios.interceptors.request.use(function (config) {
|
||||||
// Do something before request is sent
|
// Do something before request is sent
|
||||||
if (store.state.auth.authenticated) {
|
if (store.state.auth.token) {
|
||||||
config.headers['Authorization'] = store.getters['auth/header']
|
config.headers['Authorization'] = store.getters['auth/header']
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
|
|
|
@ -19,6 +19,14 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
reset (state) {
|
||||||
|
state.authenticated = false
|
||||||
|
state.profile = null
|
||||||
|
state.username = ''
|
||||||
|
state.token = ''
|
||||||
|
state.tokenData = {}
|
||||||
|
state.availablePermissions = {}
|
||||||
|
},
|
||||||
profile: (state, value) => {
|
profile: (state, value) => {
|
||||||
state.profile = value
|
state.profile = value
|
||||||
},
|
},
|
||||||
|
@ -53,8 +61,6 @@ export default {
|
||||||
return axios.post('token/', credentials).then(response => {
|
return axios.post('token/', credentials).then(response => {
|
||||||
logger.default.info('Successfully logged in as', credentials.username)
|
logger.default.info('Successfully logged in as', credentials.username)
|
||||||
commit('token', response.data.token)
|
commit('token', response.data.token)
|
||||||
commit('username', credentials.username)
|
|
||||||
commit('authenticated', true)
|
|
||||||
dispatch('fetchProfile')
|
dispatch('fetchProfile')
|
||||||
// Redirect to a specified route
|
// Redirect to a specified route
|
||||||
router.push(next)
|
router.push(next)
|
||||||
|
@ -64,19 +70,25 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
logout ({commit}) {
|
logout ({commit}) {
|
||||||
commit('authenticated', false)
|
let modules = [
|
||||||
|
'auth',
|
||||||
|
'favorites',
|
||||||
|
'player',
|
||||||
|
'playlists',
|
||||||
|
'queue',
|
||||||
|
'radios'
|
||||||
|
]
|
||||||
|
modules.forEach(m => {
|
||||||
|
commit(`${m}/reset`, null, {root: true})
|
||||||
|
})
|
||||||
logger.default.info('Log out, goodbye!')
|
logger.default.info('Log out, goodbye!')
|
||||||
router.push({name: 'index'})
|
router.push({name: 'index'})
|
||||||
},
|
},
|
||||||
check ({commit, dispatch, state}) {
|
check ({commit, dispatch, state}) {
|
||||||
logger.default.info('Checking authentication...')
|
logger.default.info('Checking authentication...')
|
||||||
var jwt = state.token
|
var jwt = state.token
|
||||||
var username = state.username
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
commit('authenticated', true)
|
|
||||||
commit('username', username)
|
|
||||||
commit('token', jwt)
|
commit('token', jwt)
|
||||||
logger.default.info('Logged back in as ' + username)
|
|
||||||
dispatch('fetchProfile')
|
dispatch('fetchProfile')
|
||||||
dispatch('refreshToken')
|
dispatch('refreshToken')
|
||||||
} else {
|
} else {
|
||||||
|
@ -88,6 +100,7 @@ export default {
|
||||||
return axios.get('users/users/me/').then((response) => {
|
return axios.get('users/users/me/').then((response) => {
|
||||||
logger.default.info('Successfully fetched user profile')
|
logger.default.info('Successfully fetched user profile')
|
||||||
let data = response.data
|
let data = response.data
|
||||||
|
commit('authenticated', true)
|
||||||
commit('profile', data)
|
commit('profile', data)
|
||||||
commit('username', data.username)
|
commit('username', data.username)
|
||||||
dispatch('favorites/fetch', null, {root: true})
|
dispatch('favorites/fetch', null, {root: true})
|
||||||
|
|
|
@ -20,6 +20,10 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.count = state.tracks.length
|
state.count = state.tracks.length
|
||||||
|
},
|
||||||
|
reset (state) {
|
||||||
|
state.tracks = []
|
||||||
|
state.count = 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
|
|
|
@ -15,6 +15,10 @@ export default {
|
||||||
looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue
|
looping: 0 // 0 -> no, 1 -> on track, 2 -> on queue
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
reset (state) {
|
||||||
|
state.errorCount = 0
|
||||||
|
state.playing = false
|
||||||
|
},
|
||||||
volume (state, value) {
|
volume (state, value) {
|
||||||
value = parseFloat(value)
|
value = parseFloat(value)
|
||||||
value = Math.min(value, 1)
|
value = Math.min(value, 1)
|
||||||
|
|
|
@ -17,6 +17,11 @@ export default {
|
||||||
},
|
},
|
||||||
showModal (state, value) {
|
showModal (state, value) {
|
||||||
state.showModal = value
|
state.showModal = value
|
||||||
|
},
|
||||||
|
reset (state) {
|
||||||
|
state.playlists = []
|
||||||
|
state.modalTrack = null
|
||||||
|
state.showModal = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
|
@ -10,6 +10,12 @@ export default {
|
||||||
previousQueue: null
|
previousQueue: null
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
reset (state) {
|
||||||
|
state.tracks = []
|
||||||
|
state.currentIndex = -1
|
||||||
|
state.ended = true
|
||||||
|
state.previousQueue = null
|
||||||
|
},
|
||||||
currentIndex (state, value) {
|
currentIndex (state, value) {
|
||||||
state.currentIndex = value
|
state.currentIndex = value
|
||||||
},
|
},
|
||||||
|
@ -86,14 +92,17 @@ export default {
|
||||||
if (current) {
|
if (current) {
|
||||||
dispatch('player/stop', null, {root: true})
|
dispatch('player/stop', null, {root: true})
|
||||||
}
|
}
|
||||||
if (index < state.currentIndex) {
|
|
||||||
dispatch('currentIndex', state.currentIndex - 1)
|
|
||||||
}
|
|
||||||
commit('splice', {start: index, size: 1})
|
commit('splice', {start: index, size: 1})
|
||||||
|
if (index < state.currentIndex) {
|
||||||
|
commit('currentIndex', state.currentIndex - 1)
|
||||||
|
}
|
||||||
if (current) {
|
if (current) {
|
||||||
// we play next track, which now have the same index
|
// we play next track, which now have the same index
|
||||||
dispatch('currentIndex', index)
|
dispatch('currentIndex', index)
|
||||||
}
|
}
|
||||||
|
if (state.currentIndex + 1 === state.tracks.length) {
|
||||||
|
dispatch('radios/populateQueue', null, {root: true})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
resume ({state, dispatch, rootState}) {
|
resume ({state, dispatch, rootState}) {
|
||||||
|
|
|
@ -26,6 +26,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
reset (state) {
|
||||||
|
state.running = false
|
||||||
|
state.current = false
|
||||||
|
},
|
||||||
current: (state, value) => {
|
current: (state, value) => {
|
||||||
state.current = value
|
state.current = value
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,16 +3,16 @@
|
||||||
<div class="ui secondary pointing menu">
|
<div class="ui secondary pointing menu">
|
||||||
<router-link
|
<router-link
|
||||||
class="ui item"
|
class="ui item"
|
||||||
:to="{name: 'federation.libraries.list'}">Libraries</router-link>
|
:to="{name: 'federation.libraries.list'}">{{ $t('Libraries') }}</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
class="ui item"
|
class="ui item"
|
||||||
:to="{name: 'federation.tracks.list'}">Tracks</router-link>
|
:to="{name: 'federation.tracks.list'}">{{ $t('Tracks') }}</router-link>
|
||||||
<div class="ui secondary right menu">
|
<div class="ui secondary right menu">
|
||||||
<router-link
|
<router-link
|
||||||
class="ui item"
|
class="ui item"
|
||||||
:to="{name: 'federation.followers.list'}">
|
:to="{name: 'federation.followers.list'}">
|
||||||
Followers
|
{{ $t('Followers') }}
|
||||||
<div class="ui teal label" title="Pending requests">{{ requestsCount }}</div>
|
<div class="ui teal label" :title="$t('Pending requests')">{{ requestsCount }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,18 +19,18 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td >
|
<td >
|
||||||
Follow status
|
{{ $t('Follow status') }}
|
||||||
<span :data-tooltip="$t('This indicate if the remote library granted you access')"><i class="question circle icon"></i></span>
|
<span :data-tooltip="$t('This indicate if the remote library granted you access')"><i class="question circle icon"></i></span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="object.follow.approved === null">
|
<template v-if="object.follow.approved === null">
|
||||||
<i class="loading icon"></i> Pending approval
|
<i class="loading icon"></i> {{ $t('Pending approval') }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="object.follow.approved === true">
|
<template v-else-if="object.follow.approved === true">
|
||||||
<i class="check icon"></i> Following
|
<i class="check icon"></i> {{ $t('Following') }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="object.follow.approved === false">
|
<template v-else-if="object.follow.approved === false">
|
||||||
<i class="x icon"></i> Not following
|
<i class="x icon"></i> {{ $t('Not following') }}
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Federation
|
{{ $t('Federation') }}
|
||||||
<span :data-tooltip="$t('Use this flag to enable/disable federation with this library')"><i class="question circle icon"></i></span>
|
<span :data-tooltip="$t('Use this flag to enable/disable federation with this library')"><i class="question circle icon"></i></span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -54,7 +54,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
Auto importing
|
{{ $t('Auto importing') }}
|
||||||
<span :data-tooltip="$t('When enabled, auto importing will automatically import new tracks published in this library')"><i class="question circle icon"></i></span>
|
<span :data-tooltip="$t('When enabled, auto importing will automatically import new tracks published in this library')"><i class="question circle icon"></i></span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -82,14 +82,14 @@
|
||||||
</tr>
|
</tr>
|
||||||
-->
|
-->
|
||||||
<tr>
|
<tr>
|
||||||
<td>Library size</td>
|
<td>{{ $t('Library size') }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ object.tracks_count }} tracks
|
{{ $t('{%count%} tracks', { count: object.tracks_count }) }}
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Last fetched</td>
|
<td>{{ $t('Last fetched') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<human-date v-if="object.fetched_date" :date="object.fetched_date"></human-date>
|
<human-date v-if="object.fetched_date" :date="object.fetched_date"></human-date>
|
||||||
<template v-else>Never</template>
|
<template v-else>Never</template>
|
||||||
|
@ -97,10 +97,10 @@
|
||||||
@click="scan"
|
@click="scan"
|
||||||
v-if="!scanTrigerred"
|
v-if="!scanTrigerred"
|
||||||
:class="['ui', 'basic', {loading: isScanLoading}, 'button']">
|
:class="['ui', 'basic', {loading: isScanLoading}, 'button']">
|
||||||
<i class="sync icon"></i> Trigger scan
|
<i class="sync icon"></i> {{ $t('Trigger scan') }}
|
||||||
</button>
|
</button>
|
||||||
<button v-else class="ui success button">
|
<button v-else class="ui success button">
|
||||||
<i class="check icon"></i> Scan triggered!
|
<i class="check icon"></i> {{ $t('Scan triggered!') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
@ -110,10 +110,10 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<button @click="fetchData" class="ui basic button">Refresh</button>
|
<button @click="fetchData" class="ui basic button">{{ $t('Refresh') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<h2>Tracks available in this library</h2>
|
<h2>{{ $t('Tracks available in this library') }}</h2>
|
||||||
<library-track-table v-if="!isLoading" :filters="{library: id}"></library-track-table>
|
<library-track-table v-if="!isLoading" :filters="{library: id}"></library-track-table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-title="'Followers'">
|
<div v-title="'Followers'">
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<h2 class="ui header">Browsing followers</h2>
|
<h2 class="ui header">{{ $t('Browsing followers') }}</h2>
|
||||||
<p>
|
<p>
|
||||||
Be careful when accepting follow requests, as it means the follower
|
{{ $t('Be careful when accepting follow requests, as it means the follower will have access to your entire library.') }}
|
||||||
will have access to your entire library.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<library-follow-table></library-follow-table>
|
<library-follow-table></library-follow-table>
|
||||||
|
|
|
@ -1,22 +1,22 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-title="'Libraries'">
|
<div v-title="'Libraries'">
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<h2 class="ui header">Browsing libraries</h2>
|
<h2 class="ui header">{{ $t('Browsing libraries') }}</h2>
|
||||||
<router-link
|
<router-link
|
||||||
class="ui basic green button"
|
class="ui basic green button"
|
||||||
:to="{name: 'federation.libraries.scan'}">
|
:to="{name: 'federation.libraries.scan'}">
|
||||||
<i class="plus icon"></i>
|
<i class="plus icon"></i>
|
||||||
Add a new library
|
{{ $t('Add a new library') }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<div :class="['ui', {'loading': isLoading}, 'form']">
|
<div :class="['ui', {'loading': isLoading}, 'form']">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Search</label>
|
<label>{{ $t('Search') }}</label>
|
||||||
<input type="text" v-model="query" placeholder="Enter an library domain name..."/>
|
<input type="text" v-model="query" placeholder="Enter an library domain name..."/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ordering</label>
|
<label>{{ $t('Ordering') }}</label>
|
||||||
<select class="ui dropdown" v-model="ordering">
|
<select class="ui dropdown" v-model="ordering">
|
||||||
<option v-for="option in orderingOptions" :value="option[0]">
|
<option v-for="option in orderingOptions" :value="option[0]">
|
||||||
{{ option[1] }}
|
{{ option[1] }}
|
||||||
|
@ -24,14 +24,14 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ordering direction</label>
|
<label>{{ $t('Ordering direction') }}</label>
|
||||||
<select class="ui dropdown" v-model="orderingDirection">
|
<select class="ui dropdown" v-model="orderingDirection">
|
||||||
<option value="">Ascending</option>
|
<option value="">{{ $t('Ascending') }}</option>
|
||||||
<option value="-">Descending</option>
|
<option value="-">{{ $t('Descending') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Results per page</label>
|
<label>{{ $t('Results per page') }}</label>
|
||||||
<select class="ui dropdown" v-model="paginateBy">
|
<select class="ui dropdown" v-model="paginateBy">
|
||||||
<option :value="parseInt(12)">12</option>
|
<option :value="parseInt(12)">12</option>
|
||||||
<option :value="parseInt(25)">25</option>
|
<option :value="parseInt(25)">25</option>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-title="'Federated tracks'">
|
<div v-title="'Federated tracks'">
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<h2 class="ui header">Browsing federated tracks</h2>
|
<h2 class="ui header">{{ $t('Browsing federated tracks') }}</h2>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
<library-track-table :show-library="true"></library-track-table>
|
<library-track-table :show-library="true"></library-track-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,10 +2,10 @@
|
||||||
<div class="main pusher" v-title="'Instance Timeline'">
|
<div class="main pusher" v-title="'Instance Timeline'">
|
||||||
<div class="ui vertical center aligned stripe segment">
|
<div class="ui vertical center aligned stripe segment">
|
||||||
<div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
|
<div v-if="isLoading" :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
|
||||||
<div class="ui text loader">Loading timeline...</div>
|
<div class="ui text loader">{{ $t('Loading timeline...') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="ui text container">
|
<div v-else class="ui text container">
|
||||||
<h1 class="ui header">Recent activity on this instance</h1>
|
<h1 class="ui header">{{ $t('Recent activity on this instance') }}</h1>
|
||||||
<div class="ui feed">
|
<div class="ui feed">
|
||||||
<component
|
<component
|
||||||
class="event"
|
class="event"
|
||||||
|
@ -14,12 +14,6 @@
|
||||||
v-if="components[event.type]"
|
v-if="components[event.type]"
|
||||||
:is="components[event.type]"
|
:is="components[event.type]"
|
||||||
:event="event">
|
: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>
|
</component>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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 :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isLoading && playlist" class="ui head vertical center aligned stripe segment" v-title="playlist.name">
|
<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>
|
<i class="circular inverted list yellow icon"></i>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{ playlist.name }}
|
{{ playlist.name }}
|
||||||
<div class="sub header">
|
<i18next tag="div" class="sub header" path="Playlist containing {%0%} tracks, by {%1%}">
|
||||||
Playlist containing {{ playlistTracks.length }} tracks,
|
{{ playlistTracks.length }}
|
||||||
by <username :username="playlist.user.username"></username>
|
<username :username="playlist.user.username"></username>
|
||||||
</div>
|
</i18next>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="ui hidden divider"></div>
|
<div class="ui hidden divider"></div>
|
||||||
</button>
|
</button>
|
||||||
<play-button class="orange" :tracks="tracks">Play all</play-button>
|
<play-button class="orange" :tracks="tracks">{{ $t('Play all') }}</play-button>
|
||||||
<button
|
<button
|
||||||
class="ui icon button"
|
class="ui icon button"
|
||||||
v-if="playlist.user.id === $store.state.auth.profile.id"
|
v-if="playlist.user.id === $store.state.auth.profile.id"
|
||||||
@click="edit = !edit">
|
@click="edit = !edit">
|
||||||
<i class="pencil icon"></i>
|
<i class="pencil icon"></i>
|
||||||
<template v-if="edit">End edition</template>
|
<template v-if="edit">{{ $t('End edition') }}</template>
|
||||||
<template v-else>Edit...</template>
|
<template v-else>{{ $t('Edit...') }}</template>
|
||||||
</button>
|
</button>
|
||||||
<dangerous-button class="labeled icon" :action="deletePlaylist">
|
<dangerous-button class="labeled icon" :action="deletePlaylist">
|
||||||
<i class="trash icon"></i> Delete
|
<i class="trash icon"></i> {{ $t('Delete') }}
|
||||||
<p slot="modal-header">Do you want to delete the playlist "{{ playlist.name }}"?</p>
|
<p slot="modal-header">{{ $t('Do you want to delete the playlist "{% playlist %}"?', {playlist: playlist.name}) }}</p>
|
||||||
<p slot="modal-content">This will completely delete this playlist and cannot be undone.</p>
|
<p slot="modal-content">{{ $t('This will completely delete this playlist and cannot be undone.') }}</p>
|
||||||
<p slot="modal-confirm">Delete playlist</p>
|
<p slot="modal-confirm">{{ $t('Delete playlist') }}</p>
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,21 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-title="'Playlists'">
|
<div v-title="$t('Playlists')">
|
||||||
<div class="ui vertical stripe segment">
|
<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']">
|
<div :class="['ui', {'loading': isLoading}, 'form']">
|
||||||
<template v-if="$store.state.auth.authenticated">
|
<template v-if="$store.state.auth.authenticated">
|
||||||
<button
|
<button
|
||||||
@click="$store.commit('playlists/chooseTrack', null)"
|
@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>
|
<div class="ui hidden divider"></div>
|
||||||
</template>
|
</template>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Search</label>
|
<label>{{ $t('Search') }}</label>
|
||||||
<input type="text" v-model="query" placeholder="Enter an playlist name..."/>
|
<input type="text" v-model="query" :placeholder="$t('Enter an playlist name...')"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ordering</label>
|
<label>{{ $t('Ordering') }}</label>
|
||||||
<select class="ui dropdown" v-model="ordering">
|
<select class="ui dropdown" v-model="ordering">
|
||||||
<option v-for="option in orderingOptions" :value="option[0]">
|
<option v-for="option in orderingOptions" :value="option[0]">
|
||||||
{{ option[1] }}
|
{{ option[1] }}
|
||||||
|
@ -23,14 +23,14 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Ordering direction</label>
|
<label>{{ $t('Ordering direction') }}</label>
|
||||||
<select class="ui dropdown" v-model="orderingDirection">
|
<select class="ui dropdown" v-model="orderingDirection">
|
||||||
<option value="">Ascending</option>
|
<option value="">{{ $t('Ascending') }}</option>
|
||||||
<option value="-">Descending</option>
|
<option value="-">{{ $t('Descending') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Results per page</label>
|
<label>{{ $t('Results per page') }}</label>
|
||||||
<select class="ui dropdown" v-model="paginateBy">
|
<select class="ui dropdown" v-model="paginateBy">
|
||||||
<option :value="parseInt(12)">12</option>
|
<option :value="parseInt(12)">12</option>
|
||||||
<option :value="parseInt(25)">25</option>
|
<option :value="parseInt(25)">25</option>
|
||||||
|
@ -76,6 +76,7 @@ export default {
|
||||||
Pagination
|
Pagination
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
|
console.log('YOLO', this.$t)
|
||||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
|
|
@ -83,7 +83,6 @@ export default {
|
||||||
axios.get(url).then((response) => {
|
axios.get(url).then((response) => {
|
||||||
self.radio = response.data
|
self.radio = response.data
|
||||||
axios.get(url + 'tracks', {params: {page: this.page}}).then((response) => {
|
axios.get(url + 'tracks', {params: {page: this.page}}).then((response) => {
|
||||||
console.log(response.data.count)
|
|
||||||
this.totalTracks = response.data.count
|
this.totalTracks = response.data.count
|
||||||
this.tracks = response.data.results
|
this.tracks = response.data.results
|
||||||
}).then(() => {
|
}).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,
|
action: store.actions.logout,
|
||||||
params: {state: {}},
|
params: {state: {}},
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'authenticated', payload: false }
|
{ type: 'auth/reset', payload: null, options: {root: true} },
|
||||||
|
{ type: 'favorites/reset', payload: null, options: {root: true} },
|
||||||
|
{ type: 'player/reset', payload: null, options: {root: true} },
|
||||||
|
{ type: 'playlists/reset', payload: null, options: {root: true} },
|
||||||
|
{ type: 'queue/reset', payload: null, options: {root: true} },
|
||||||
|
{ type: 'radios/reset', payload: null, options: {root: true} }
|
||||||
]
|
]
|
||||||
}, done)
|
}, done)
|
||||||
})
|
})
|
||||||
|
@ -107,8 +112,6 @@ describe('store/auth', () => {
|
||||||
action: store.actions.check,
|
action: store.actions.check,
|
||||||
params: {state: {token: 'test', username: 'user'}},
|
params: {state: {token: 'test', username: 'user'}},
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'authenticated', payload: true },
|
|
||||||
{ type: 'username', payload: 'user' },
|
|
||||||
{ type: 'token', payload: 'test' }
|
{ type: 'token', payload: 'test' }
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
|
@ -131,9 +134,7 @@ describe('store/auth', () => {
|
||||||
action: store.actions.login,
|
action: store.actions.login,
|
||||||
payload: {credentials: credentials},
|
payload: {credentials: credentials},
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'token', payload: 'test' },
|
{ type: 'token', payload: 'test' }
|
||||||
{ type: 'username', payload: 'bob' },
|
|
||||||
{ type: 'authenticated', payload: true }
|
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
{ type: 'fetchProfile' }
|
{ type: 'fetchProfile' }
|
||||||
|
@ -175,13 +176,14 @@ describe('store/auth', () => {
|
||||||
testAction({
|
testAction({
|
||||||
action: store.actions.fetchProfile,
|
action: store.actions.fetchProfile,
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
|
{ type: 'authenticated', payload: true },
|
||||||
{ type: 'profile', payload: profile },
|
{ type: 'profile', payload: profile },
|
||||||
{ type: 'username', payload: profile.username },
|
{ type: 'username', payload: profile.username },
|
||||||
{ type: 'permission', payload: {key: 'admin', status: true} }
|
{ type: 'permission', payload: {key: 'admin', status: true} }
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
{ type: 'favorites/fetch', payload: null, options: {root: true} },
|
{ type: 'favorites/fetch', payload: null, options: {root: true} },
|
||||||
{ type: 'playlists/fetchOwn', payload: null, options: {root: true} },
|
{ type: 'playlists/fetchOwn', payload: null, options: {root: true} }
|
||||||
]
|
]
|
||||||
}, done)
|
}, done)
|
||||||
})
|
})
|
||||||
|
|
|
@ -158,9 +158,7 @@ describe('store/queue', () => {
|
||||||
payload: 1,
|
payload: 1,
|
||||||
params: {state: {currentIndex: 2}},
|
params: {state: {currentIndex: 2}},
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'splice', payload: {start: 1, size: 1} }
|
{ type: 'splice', payload: {start: 1, size: 1} },
|
||||||
],
|
|
||||||
expectedActions: [
|
|
||||||
{ type: 'currentIndex', payload: 1 }
|
{ type: 'currentIndex', payload: 1 }
|
||||||
]
|
]
|
||||||
}, done)
|
}, done)
|
||||||
|
|
|
@ -97,6 +97,5 @@ describe('store/radios', () => {
|
||||||
expectedActions: []
|
expectedActions: []
|
||||||
}, done)
|
}, done)
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
// helper for testing action with expected mutations
|
// helper for testing action with expected mutations
|
||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
export const render = (Component, propsData) => {
|
||||||
|
const Constructor = Vue.extend(Component)
|
||||||
|
return new Constructor({ propsData: propsData }).$mount()
|
||||||
|
}
|
||||||
|
|
||||||
export const testAction = ({action, payload, params, expectedMutations, expectedActions}, done) => {
|
export const testAction = ({action, payload, params, expectedMutations, expectedActions}, done) => {
|
||||||
let mutationsCount = 0
|
let mutationsCount = 0
|
||||||
let actionsCount = 0
|
let actionsCount = 0
|
||||||
|
|
Loading…
Reference in New Issue