Merge branch 'release/0.5'
This commit is contained in:
commit
ce482314e7
|
@ -72,7 +72,7 @@ api/music
|
|||
api/media
|
||||
api/staticfiles
|
||||
api/static
|
||||
|
||||
api/.pytest_cache
|
||||
|
||||
# Front
|
||||
front/node_modules/
|
||||
|
|
38
CHANGELOG
38
CHANGELOG
|
@ -1,11 +1,45 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
|
||||
0.5 (Unreleased)
|
||||
0.6 (Unreleased)
|
||||
----------------
|
||||
|
||||
|
||||
0.5 (2018-02-24)
|
||||
----------------
|
||||
|
||||
- Front: Now reset player colors when track has no cover (#46)
|
||||
- Front: play button now disabled for unplayable tracks
|
||||
- API: You can now enable or disable registration on the fly, via a preference (#58)
|
||||
- Front: can now signup via the web interface (#35)
|
||||
- Front: Fixed broken redirection on login
|
||||
- Front: Fixed broken error handling on settings and login form
|
||||
|
||||
About page:
|
||||
|
||||
There is a brand new about page on instances (/about), and instance
|
||||
owner can now provide a name, a short and a long description for their instance via the admin (/api/admin/dynamic_preferences/globalpreferencemodel/).
|
||||
|
||||
Transcoding:
|
||||
|
||||
Basic transcoding is now available to/from the following formats : ogg and mp3.
|
||||
|
||||
*This is still an alpha feature at the moment, please report any bug.*
|
||||
|
||||
This relies internally on FFMPEG and can put some load on your server.
|
||||
It's definitely recommended you setup some caching for the transcoded files
|
||||
at your webserver level. Check the the exemple nginx file at deploy/nginx.conf
|
||||
for an implementation.
|
||||
|
||||
On the frontend, usage of transcoding should be transparent in the player.
|
||||
|
||||
Music Requests:
|
||||
|
||||
This release includes a new feature, music requests, which allows users
|
||||
to request music they'd like to see imported.
|
||||
Admins can browse those requests and mark them as completed when
|
||||
an import is made.
|
||||
|
||||
0.4 (2018-02-18)
|
||||
----------------
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ FROM python:3.5
|
|||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
|
||||
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
|
||||
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
|
||||
|
|
|
@ -52,6 +52,10 @@ v1_patterns += [
|
|||
include(
|
||||
('funkwhale_api.users.api_urls', 'users'),
|
||||
namespace='users')),
|
||||
url(r'^requests/',
|
||||
include(
|
||||
('funkwhale_api.requests.api_urls', 'requests'),
|
||||
namespace='requests')),
|
||||
url(r'^token/$', jwt_views.obtain_jwt_token, name='token'),
|
||||
url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
|
||||
]
|
||||
|
|
|
@ -80,10 +80,12 @@ if RAVEN_ENABLED:
|
|||
|
||||
# Apps specific for this project go here.
|
||||
LOCAL_APPS = (
|
||||
'funkwhale_api.common',
|
||||
'funkwhale_api.users', # custom users app
|
||||
# Your stuff: custom apps go here
|
||||
'funkwhale_api.instance',
|
||||
'funkwhale_api.music',
|
||||
'funkwhale_api.requests',
|
||||
'funkwhale_api.favorites',
|
||||
'funkwhale_api.radios',
|
||||
'funkwhale_api.history',
|
||||
|
@ -262,7 +264,7 @@ AUTHENTICATION_BACKENDS = (
|
|||
)
|
||||
|
||||
# Some really nice defaults
|
||||
ACCOUNT_AUTHENTICATION_METHOD = 'username'
|
||||
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
|
||||
|
||||
|
@ -315,7 +317,6 @@ CORS_ORIGIN_ALLOW_ALL = True
|
|||
# )
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
|
||||
REGISTRATION_MODE = env('REGISTRATION_MODE', default='disabled')
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
|
|
|
@ -13,8 +13,8 @@ urlpatterns = [
|
|||
url(settings.ADMIN_URL, admin.site.urls),
|
||||
|
||||
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
|
||||
url(r'^api/auth/', include('rest_auth.urls')),
|
||||
url(r'^api/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
|
||||
url(r'^api/v1/auth/', include('rest_auth.urls')),
|
||||
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
|
||||
url(r'^accounts/', include('allauth.urls')),
|
||||
|
||||
# Your stuff: custom urls includes go here
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
FROM python:3.5
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
|
||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
COPY ./install_os_dependencies.sh /install_os_dependencies.sh
|
||||
RUN bash install_os_dependencies.sh install
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
__version__ = '0.4'
|
||||
__version__ = '0.5'
|
||||
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
|
||||
|
|
|
@ -17,9 +17,6 @@ class ListeningViewSet(mixins.CreateModelMixin,
|
|||
queryset = models.Listening.objects.all()
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.request.user.is_authenticated:
|
||||
|
|
|
@ -1,9 +1,42 @@
|
|||
from django.forms import widgets
|
||||
|
||||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
raven = types.Section('raven')
|
||||
instance = types.Section('instance')
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class InstanceName(types.StringPreference):
|
||||
show_in_api = True
|
||||
section = instance
|
||||
name = 'name'
|
||||
default = ''
|
||||
help_text = 'Instance public name'
|
||||
verbose_name = 'The public name of your instance'
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class InstanceShortDescription(types.StringPreference):
|
||||
show_in_api = True
|
||||
section = instance
|
||||
name = 'short_description'
|
||||
default = ''
|
||||
verbose_name = 'Instance succinct description'
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class InstanceLongDescription(types.StringPreference):
|
||||
show_in_api = True
|
||||
section = instance
|
||||
name = 'long_description'
|
||||
default = ''
|
||||
help_text = 'Instance long description (markdown allowed)'
|
||||
field_kwargs = {
|
||||
'widget': widgets.Textarea
|
||||
}
|
||||
|
||||
@global_preferences_registry.register
|
||||
class RavenDSN(types.StringPreference):
|
||||
show_in_api = True
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
from django import forms
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class TranscodeForm(forms.Form):
|
||||
FORMAT_CHOICES = [
|
||||
('ogg', 'ogg'),
|
||||
('mp3', 'mp3'),
|
||||
]
|
||||
|
||||
to = forms.ChoiceField(choices=FORMAT_CHOICES)
|
||||
BITRATE_CHOICES = [
|
||||
(64, '64'),
|
||||
(128, '128'),
|
||||
(256, '256'),
|
||||
]
|
||||
bitrate = forms.ChoiceField(
|
||||
choices=BITRATE_CHOICES, required=False)
|
||||
|
||||
track_file = forms.ModelChoiceField(
|
||||
queryset=models.TrackFile.objects.all()
|
||||
)
|
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 2.0.2 on 2018-02-18 15:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0017_auto_20171227_1728'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='trackfile',
|
||||
name='mimetype',
|
||||
field=models.CharField(blank=True, max_length=200, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importjob',
|
||||
name='source',
|
||||
field=models.CharField(max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importjob',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import os
|
||||
|
||||
from django.db import migrations, models
|
||||
from funkwhale_api.music.utils import guess_mimetype
|
||||
|
||||
|
||||
def populate_mimetype(apps, schema_editor):
|
||||
TrackFile = apps.get_model("music", "TrackFile")
|
||||
|
||||
for tf in TrackFile.objects.filter(audio_file__isnull=False, mimetype__isnull=True).only('audio_file'):
|
||||
try:
|
||||
tf.mimetype = guess_mimetype(tf.audio_file)
|
||||
except Exception as e:
|
||||
print('Error on track file {}: {}'.format(tf.pk, e))
|
||||
continue
|
||||
print('Track file {}: {}'.format(tf.pk, tf.mimetype))
|
||||
tf.save(update_fields=['mimetype'])
|
||||
|
||||
|
||||
def rewind(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0018_auto_20180218_1554'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_mimetype, rewind),
|
||||
]
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.0.2 on 2018-02-20 19:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0019_populate_mimetypes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='importbatch',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('pending', 'Pending'), ('finished', 'Finished'), ('errored', 'Errored'), ('skipped', 'Skipped')], default='pending', max_length=30),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import os
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def populate_status(apps, schema_editor):
|
||||
from funkwhale_api.music.utils import compute_status
|
||||
ImportBatch = apps.get_model("music", "ImportBatch")
|
||||
|
||||
for ib in ImportBatch.objects.prefetch_related('jobs'):
|
||||
ib.status = compute_status(ib.jobs.all())
|
||||
ib.save(update_fields=['status'])
|
||||
|
||||
|
||||
def rewind(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0020_importbatch_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_status, rewind),
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 2.0.2 on 2018-02-20 22:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('requests', '__first__'),
|
||||
('music', '0021_populate_batch_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='importbatch',
|
||||
name='import_request',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='import_batches', to='requests.ImportRequest'),
|
||||
),
|
||||
]
|
|
@ -10,14 +10,18 @@ from django.conf import settings
|
|||
from django.db import models
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files import File
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from taggit.managers import TaggableManager
|
||||
from versatileimagefield.fields import VersatileImageField
|
||||
|
||||
from funkwhale_api import downloader
|
||||
from funkwhale_api import musicbrainz
|
||||
from . import importers
|
||||
from . import utils
|
||||
|
||||
|
||||
class APIModelMixin(models.Model):
|
||||
|
@ -364,6 +368,7 @@ class TrackFile(models.Model):
|
|||
source = models.URLField(null=True, blank=True)
|
||||
duration = models.IntegerField(null=True, blank=True)
|
||||
acoustid_track_id = models.UUIDField(null=True, blank=True)
|
||||
mimetype = models.CharField(null=True, blank=True, max_length=200)
|
||||
|
||||
def download_file(self):
|
||||
# import the track file, since there is not any
|
||||
|
@ -393,6 +398,18 @@ class TrackFile(models.Model):
|
|||
self.track.full_name,
|
||||
os.path.splitext(self.audio_file.name)[-1])
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.mimetype and self.audio_file:
|
||||
self.mimetype = utils.guess_mimetype(self.audio_file)
|
||||
return super().save(**kwargs)
|
||||
|
||||
|
||||
IMPORT_STATUS_CHOICES = (
|
||||
('pending', 'Pending'),
|
||||
('finished', 'Finished'),
|
||||
('errored', 'Errored'),
|
||||
('skipped', 'Skipped'),
|
||||
)
|
||||
|
||||
class ImportBatch(models.Model):
|
||||
IMPORT_BATCH_SOURCES = [
|
||||
|
@ -406,22 +423,24 @@ class ImportBatch(models.Model):
|
|||
'users.User',
|
||||
related_name='imports',
|
||||
on_delete=models.CASCADE)
|
||||
|
||||
status = models.CharField(
|
||||
choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
|
||||
import_request = models.ForeignKey(
|
||||
'requests.ImportRequest',
|
||||
related_name='import_batches',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE)
|
||||
class Meta:
|
||||
ordering = ['-creation_date']
|
||||
|
||||
def __str__(self):
|
||||
return str(self.pk)
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
pending = any([job.status == 'pending' for job in self.jobs.all()])
|
||||
errored = any([job.status == 'errored' for job in self.jobs.all()])
|
||||
if pending:
|
||||
return 'pending'
|
||||
if errored:
|
||||
return 'errored'
|
||||
return 'finished'
|
||||
def update_status(self):
|
||||
self.status = utils.compute_status(self.jobs.all())
|
||||
self.save(update_fields=['status'])
|
||||
|
||||
|
||||
class ImportJob(models.Model):
|
||||
batch = models.ForeignKey(
|
||||
|
@ -434,15 +453,39 @@ class ImportJob(models.Model):
|
|||
on_delete=models.CASCADE)
|
||||
source = models.CharField(max_length=500)
|
||||
mbid = models.UUIDField(editable=False, null=True, blank=True)
|
||||
STATUS_CHOICES = (
|
||||
('pending', 'Pending'),
|
||||
('finished', 'Finished'),
|
||||
('errored', 'Errored'),
|
||||
('skipped', 'Skipped'),
|
||||
)
|
||||
status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30)
|
||||
|
||||
status = models.CharField(
|
||||
choices=IMPORT_STATUS_CHOICES, default='pending', max_length=30)
|
||||
audio_file = models.FileField(
|
||||
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ('id', )
|
||||
|
||||
|
||||
@receiver(post_save, sender=ImportJob)
|
||||
def update_batch_status(sender, instance, **kwargs):
|
||||
instance.batch.update_status()
|
||||
|
||||
|
||||
@receiver(post_save, sender=ImportBatch)
|
||||
def update_request_status(sender, instance, created, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', []) or []
|
||||
if not instance.import_request:
|
||||
return
|
||||
|
||||
if not created and not 'status' in update_fields:
|
||||
return
|
||||
|
||||
r_status = instance.import_request.status
|
||||
status = instance.status
|
||||
|
||||
if status == 'pending' and r_status == 'pending':
|
||||
# let's mark the request as accepted since we started an import
|
||||
instance.import_request.status = 'accepted'
|
||||
return instance.import_request.save(update_fields=['status'])
|
||||
|
||||
if status == 'finished' and r_status == 'accepted':
|
||||
# let's mark the request as imported since the import is over
|
||||
instance.import_request.status = 'imported'
|
||||
return instance.import_request.save(update_fields=['status'])
|
||||
|
|
|
@ -28,7 +28,14 @@ class TrackFileSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = models.TrackFile
|
||||
fields = ('id', 'path', 'duration', 'source', 'filename', 'track')
|
||||
fields = (
|
||||
'id',
|
||||
'path',
|
||||
'duration',
|
||||
'source',
|
||||
'filename',
|
||||
'mimetype',
|
||||
'track')
|
||||
|
||||
def get_path(self, o):
|
||||
url = o.path
|
||||
|
@ -118,5 +125,5 @@ class ImportBatchSerializer(serializers.ModelSerializer):
|
|||
jobs = ImportJobSerializer(many=True, read_only=True)
|
||||
class Meta:
|
||||
model = models.ImportBatch
|
||||
fields = ('id', 'jobs', 'status', 'creation_date')
|
||||
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
|
||||
read_only_fields = ('creation_date',)
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import magic
|
||||
import re
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
def normalize_query(query_string,
|
||||
findterms=re.compile(r'"([^"]+)"|(\S+)').findall,
|
||||
normspace=re.compile(r'\s{2,}').sub):
|
||||
|
@ -15,6 +17,7 @@ def normalize_query(query_string,
|
|||
'''
|
||||
return [normspace(' ', (t[0] or t[1]).strip()) for t in findterms(query_string)]
|
||||
|
||||
|
||||
def get_query(query_string, search_fields):
|
||||
''' Returns a query, that is a combination of Q objects. That combination
|
||||
aims to search keywords within a model by testing the given search fields.
|
||||
|
@ -35,3 +38,18 @@ def get_query(query_string, search_fields):
|
|||
else:
|
||||
query = query & or_query
|
||||
return query
|
||||
|
||||
|
||||
def guess_mimetype(f):
|
||||
b = min(100000, f.size)
|
||||
return magic.from_buffer(f.read(b), mime=True)
|
||||
|
||||
|
||||
def compute_status(jobs):
|
||||
errored = any([job.status == 'errored' for job in jobs])
|
||||
if errored:
|
||||
return 'errored'
|
||||
pending = any([job.status == 'pending' for job in jobs])
|
||||
if pending:
|
||||
return 'pending'
|
||||
return 'finished'
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import ffmpeg
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import unicodedata
|
||||
import urllib
|
||||
|
||||
from django.urls import reverse
|
||||
from django.db import models, transaction
|
||||
from django.db.models.functions import Length
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
from rest_framework import viewsets, views, mixins
|
||||
from rest_framework.decorators import detail_route, list_route
|
||||
from rest_framework.response import Response
|
||||
|
@ -14,11 +19,13 @@ from musicbrainzngs import ResponseError
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
from funkwhale_api.requests.models import ImportRequest
|
||||
from funkwhale_api.musicbrainz import api
|
||||
from funkwhale_api.common.permissions import (
|
||||
ConditionalAuthentication, HasModelPermission)
|
||||
from taggit.models import Tag
|
||||
|
||||
from . import forms
|
||||
from . import models
|
||||
from . import serializers
|
||||
from . import importers
|
||||
|
@ -183,6 +190,40 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
f.audio_file.url)
|
||||
return response
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def viewable(self, request, *args, **kwargs):
|
||||
return Response({}, status=200)
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def transcode(self, request, *args, **kwargs):
|
||||
form = forms.TranscodeForm(request.GET)
|
||||
if not form.is_valid():
|
||||
return Response(form.errors, status=400)
|
||||
|
||||
f = form.cleaned_data['track_file']
|
||||
output_kwargs = {
|
||||
'format': form.cleaned_data['to']
|
||||
}
|
||||
args = (ffmpeg
|
||||
.input(f.audio_file.path)
|
||||
.output('pipe:', **output_kwargs)
|
||||
.get_args()
|
||||
)
|
||||
# we use a generator here so the view return immediatly and send
|
||||
# file chunk to the browser, instead of blocking a few seconds
|
||||
def _transcode():
|
||||
p = subprocess.Popen(
|
||||
['ffmpeg'] + args,
|
||||
stdout=subprocess.PIPE)
|
||||
for line in p.stdout:
|
||||
yield line
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
_transcode(), status=200,
|
||||
content_type=form.cleaned_data['to'])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class TagViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Tag.objects.all().order_by('name')
|
||||
|
@ -274,14 +315,28 @@ class SubmitViewSet(viewsets.ViewSet):
|
|||
serializer = serializers.ImportBatchSerializer(batch)
|
||||
return Response(serializer.data)
|
||||
|
||||
def get_import_request(self, data):
|
||||
try:
|
||||
raw = data['importRequest']
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
pk = int(raw)
|
||||
try:
|
||||
return ImportRequest.objects.get(pk=pk)
|
||||
except ImportRequest.DoesNotExist:
|
||||
pass
|
||||
|
||||
@list_route(methods=['post'])
|
||||
@transaction.non_atomic_requests
|
||||
def album(self, request, *args, **kwargs):
|
||||
data = json.loads(request.body.decode('utf-8'))
|
||||
import_data, batch = self._import_album(data, request, batch=None)
|
||||
import_request = self.get_import_request(data)
|
||||
import_data, batch = self._import_album(
|
||||
data, request, batch=None, import_request=import_request)
|
||||
return Response(import_data)
|
||||
|
||||
def _import_album(self, data, request, batch=None):
|
||||
def _import_album(self, data, request, batch=None, import_request=None):
|
||||
# we import the whole album here to prevent race conditions that occurs
|
||||
# when using get_or_create_from_api in tasks
|
||||
album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
|
||||
|
@ -292,7 +347,9 @@ class SubmitViewSet(viewsets.ViewSet):
|
|||
except ResponseError:
|
||||
pass
|
||||
if not batch:
|
||||
batch = models.ImportBatch.objects.create(submitted_by=request.user)
|
||||
batch = models.ImportBatch.objects.create(
|
||||
submitted_by=request.user,
|
||||
import_request=import_request)
|
||||
for row in data['tracks']:
|
||||
try:
|
||||
models.TrackFile.objects.get(track__mbid=row['mbid'])
|
||||
|
@ -306,6 +363,7 @@ class SubmitViewSet(viewsets.ViewSet):
|
|||
@transaction.non_atomic_requests
|
||||
def artist(self, request, *args, **kwargs):
|
||||
data = json.loads(request.body.decode('utf-8'))
|
||||
import_request = self.get_import_request(data)
|
||||
artist_data = api.artists.get(id=data['artistId'])['artist']
|
||||
cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
|
||||
artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
|
||||
|
@ -313,7 +371,8 @@ class SubmitViewSet(viewsets.ViewSet):
|
|||
import_data = []
|
||||
batch = None
|
||||
for row in data['albums']:
|
||||
row_data, batch = self._import_album(row, request, batch=batch)
|
||||
row_data, batch = self._import_album(
|
||||
row, request, batch=batch, import_request=import_request)
|
||||
import_data.append(row_data)
|
||||
|
||||
return Response(import_data[0])
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
from django.conf.urls import include, url
|
||||
from . import views
|
||||
|
||||
from rest_framework import routers
|
||||
router = routers.SimpleRouter()
|
||||
router.register(
|
||||
r'import-requests',
|
||||
views.ImportRequestViewSet,
|
||||
'import-requests')
|
||||
|
||||
urlpatterns = router.urls
|
|
@ -0,0 +1,15 @@
|
|||
import factory
|
||||
|
||||
from funkwhale_api.factories import registry
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
|
||||
|
||||
@registry.register
|
||||
class ImportRequestFactory(factory.django.DjangoModelFactory):
|
||||
artist_name = factory.Faker('name')
|
||||
albums = factory.Faker('sentence')
|
||||
user = factory.SubFactory(UserFactory)
|
||||
comment = factory.Faker('paragraph')
|
||||
|
||||
class Meta:
|
||||
model = 'requests.ImportRequest'
|
|
@ -0,0 +1,14 @@
|
|||
import django_filters
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class ImportRequestFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = models.ImportRequest
|
||||
fields = {
|
||||
'artist_name': ['exact', 'iexact', 'startswith', 'icontains'],
|
||||
'status': ['exact'],
|
||||
'user__username': ['exact'],
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 2.0.2 on 2018-02-20 22:49
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ImportRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('imported_date', models.DateTimeField(blank=True, null=True)),
|
||||
('artist_name', models.CharField(max_length=250)),
|
||||
('albums', models.CharField(blank=True, max_length=3000, null=True)),
|
||||
('status', models.CharField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('imported', 'imported'), ('closed', 'closed')], default='pending', max_length=50)),
|
||||
('comment', models.TextField(blank=True, max_length=3000, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_requests', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1,29 @@
|
|||
from django.db import models
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
NATURE_CHOICES = [
|
||||
('artist', 'artist'),
|
||||
('album', 'album'),
|
||||
('track', 'track'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'pending'),
|
||||
('accepted', 'accepted'),
|
||||
('imported', 'imported'),
|
||||
('closed', 'closed'),
|
||||
]
|
||||
|
||||
class ImportRequest(models.Model):
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
imported_date = models.DateTimeField(null=True, blank=True)
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
related_name='import_requests',
|
||||
on_delete=models.CASCADE)
|
||||
artist_name = models.CharField(max_length=250)
|
||||
albums = models.CharField(max_length=3000, null=True, blank=True)
|
||||
status = models.CharField(
|
||||
choices=STATUS_CHOICES, max_length=50, default='pending')
|
||||
comment = models.TextField(null=True, blank=True, max_length=3000)
|
|
@ -0,0 +1,30 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.users.serializers import UserBasicSerializer
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class ImportRequestSerializer(serializers.ModelSerializer):
|
||||
user = UserBasicSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.ImportRequest
|
||||
fields = (
|
||||
'id',
|
||||
'status',
|
||||
'albums',
|
||||
'artist_name',
|
||||
'user',
|
||||
'creation_date',
|
||||
'imported_date',
|
||||
'comment')
|
||||
read_only_fields = (
|
||||
'creation_date',
|
||||
'imported_date',
|
||||
'user',
|
||||
'status')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['user']
|
||||
return super().create(validated_data)
|
|
@ -0,0 +1,36 @@
|
|||
from rest_framework import generics, mixins, viewsets
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import detail_route
|
||||
|
||||
from funkwhale_api.music.views import SearchMixin
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ImportRequestViewSet(
|
||||
SearchMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
|
||||
serializer_class = serializers.ImportRequestSerializer
|
||||
queryset = (
|
||||
models.ImportRequest.objects.all()
|
||||
.select_related()
|
||||
.order_by('-creation_date'))
|
||||
search_fields = ['artist_name', 'album_name', 'comment']
|
||||
filter_class = filters.ImportRequestFilter
|
||||
ordering_fields = ('id', 'artist_name', 'creation_date', 'status')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
return serializer.save(user=self.request.user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
if self.request.user.is_authenticated:
|
||||
context['user'] = self.request.user
|
||||
return context
|
|
@ -1,15 +1,10 @@
|
|||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
|
||||
from django.conf import settings
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
|
||||
class FunkwhaleAccountAdapter(DefaultAccountAdapter):
|
||||
|
||||
def is_open_for_signup(self, request):
|
||||
|
||||
if settings.REGISTRATION_MODE == "disabled":
|
||||
return False
|
||||
if settings.REGISTRATION_MODE == "public":
|
||||
return True
|
||||
|
||||
return False
|
||||
manager = global_preferences_registry.manager()
|
||||
return manager['users__registration_enabled']
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
from dynamic_preferences import types
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
users = types.Section('users')
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class RegistrationEnabled(types.BooleanPreference):
|
||||
show_in_api = True
|
||||
section = users
|
||||
name = 'registration_enabled'
|
||||
default = False
|
||||
verbose_name = (
|
||||
'Can visitors open a new account on this instance?'
|
||||
)
|
|
@ -3,6 +3,12 @@ from rest_framework import serializers
|
|||
from . import models
|
||||
|
||||
|
||||
class UserBasicSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ['id', 'username', 'name', 'date_joined']
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
permissions = serializers.SerializerMethodField()
|
||||
|
|
|
@ -5,6 +5,7 @@ libjpeg-dev
|
|||
zlib1g-dev
|
||||
libpq-dev
|
||||
postgresql-client
|
||||
libav-tools
|
||||
libmagic-dev
|
||||
ffmpeg
|
||||
python3-dev
|
||||
curl
|
||||
|
|
|
@ -57,3 +57,5 @@ git+https://github.com/EliotBerriot/django-cachalot.git@django-2
|
|||
django-dynamic-preferences>=1.5,<1.6
|
||||
pyacoustid>=1.1.5,<1.2
|
||||
raven>=6.5,<7
|
||||
python-magic==0.4.15
|
||||
ffmpeg-python==0.1.10
|
||||
|
|
|
@ -56,6 +56,24 @@ def api_client(client):
|
|||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_api_client(db, factories, api_client):
|
||||
user = factories['users.User']()
|
||||
assert api_client.login(username=user.username, password='test')
|
||||
setattr(api_client, 'user', user)
|
||||
yield api_client
|
||||
delattr(api_client, 'user')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def superuser_api_client(db, factories, api_client):
|
||||
user = factories['users.SuperUser']()
|
||||
assert api_client.login(username=user.username, password='test')
|
||||
setattr(api_client, 'user', user)
|
||||
yield api_client
|
||||
delattr(api_client, 'user')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def superuser_client(db, factories, client):
|
||||
user = factories['users.SuperUser']()
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from dynamic_preferences.api import serializers
|
||||
|
@ -20,3 +22,14 @@ def test_can_list_settings_via_api(preferences, api_client):
|
|||
for p in response.data:
|
||||
i = '__'.join([p['section'], p['name']])
|
||||
assert i in expected_preferences
|
||||
|
||||
|
||||
@pytest.mark.parametrize('pref,value', [
|
||||
('instance__name', 'My instance'),
|
||||
('instance__short_description', 'For music lovers'),
|
||||
('instance__long_description', 'For real music lovers'),
|
||||
])
|
||||
def test_instance_settings(pref, value, preferences):
|
||||
preferences[pref] = value
|
||||
|
||||
assert preferences[pref] == value
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import json
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from . import data as api_data
|
||||
|
||||
|
||||
def test_create_import_can_bind_to_request(
|
||||
mocker, factories, superuser_api_client):
|
||||
request = factories['requests.ImportRequest']()
|
||||
|
||||
mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||
mocker.patch(
|
||||
'funkwhale_api.musicbrainz.api.artists.get',
|
||||
return_value=api_data.artists['get']['soad'])
|
||||
mocker.patch(
|
||||
'funkwhale_api.musicbrainz.api.images.get_front',
|
||||
return_value=b'')
|
||||
mocker.patch(
|
||||
'funkwhale_api.musicbrainz.api.releases.get',
|
||||
return_value=api_data.albums['get_with_includes']['hypnotize'])
|
||||
payload = {
|
||||
'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
|
||||
'importRequest': request.pk,
|
||||
'tracks': [
|
||||
{
|
||||
'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
|
||||
'source': 'https://www.youtube.com/watch?v=1111111111',
|
||||
}
|
||||
]
|
||||
}
|
||||
url = reverse('api:v1:submit-album')
|
||||
response = superuser_api_client.post(
|
||||
url, json.dumps(payload), content_type='application/json')
|
||||
batch = request.import_batches.latest('id')
|
||||
|
||||
assert batch.import_request == request
|
|
@ -1,9 +1,12 @@
|
|||
import os
|
||||
import pytest
|
||||
|
||||
from funkwhale_api.music import models
|
||||
from funkwhale_api.music import importers
|
||||
from funkwhale_api.music import tasks
|
||||
|
||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def test_can_store_release_group_id_on_album(factories):
|
||||
album = factories['music.Album']()
|
||||
|
@ -48,3 +51,29 @@ def test_import_job_is_bound_to_track_file(factories, mocker):
|
|||
tasks.import_job_run(import_job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
assert job.track_file.track == track
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', ['pending', 'errored', 'finished'])
|
||||
def test_saving_job_updates_batch_status(status,factories, mocker):
|
||||
batch = factories['music.ImportBatch']()
|
||||
|
||||
assert batch.status == 'pending'
|
||||
|
||||
job = factories['music.ImportJob'](batch=batch, status=status)
|
||||
|
||||
batch.refresh_from_db()
|
||||
|
||||
assert batch.status == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extention,mimetype', [
|
||||
('ogg', 'audio/ogg'),
|
||||
('mp3', 'audio/mpeg'),
|
||||
])
|
||||
def test_audio_track_mime_type(extention, mimetype, factories):
|
||||
|
||||
name = '.'.join(['test', extention])
|
||||
path = os.path.join(DATA_DIR, name)
|
||||
tf = factories['music.TrackFile'](audio_file__from_path=path)
|
||||
|
||||
assert tf.mimetype == mimetype
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import pytest
|
||||
|
||||
from django.forms import ValidationError
|
||||
|
||||
|
||||
def test_can_bind_import_batch_to_request(factories):
|
||||
request = factories['requests.ImportRequest']()
|
||||
|
||||
assert request.status == 'pending'
|
||||
|
||||
# when we create the import, we consider the request as accepted
|
||||
batch = factories['music.ImportBatch'](import_request=request)
|
||||
request.refresh_from_db()
|
||||
|
||||
assert request.status == 'accepted'
|
||||
|
||||
# now, the batch is finished, therefore the request status should be
|
||||
# imported
|
||||
batch.status = 'finished'
|
||||
batch.save(update_fields=['status'])
|
||||
request.refresh_from_db()
|
||||
|
||||
assert request.status == 'imported'
|
|
@ -0,0 +1,26 @@
|
|||
from django.urls import reverse
|
||||
|
||||
|
||||
def test_request_viewset_requires_auth(db, api_client):
|
||||
url = reverse('api:v1:requests:import-requests-list')
|
||||
response = api_client.get(url)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_can_create_request(logged_in_api_client):
|
||||
url = reverse('api:v1:requests:import-requests-list')
|
||||
user = logged_in_api_client.user
|
||||
data = {
|
||||
'artist_name': 'System of a Down',
|
||||
'albums': 'All please!',
|
||||
'comment': 'Please, they rock!',
|
||||
}
|
||||
response = logged_in_api_client.post(url, data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
ir = user.import_requests.latest('id')
|
||||
assert ir.status == 'pending'
|
||||
assert ir.creation_date is not None
|
||||
for field, value in data.items():
|
||||
assert getattr(ir, field) == value
|
|
@ -6,7 +6,7 @@ from django.urls import reverse
|
|||
from funkwhale_api.users.models import User
|
||||
|
||||
|
||||
def test_can_create_user_via_api(settings, client, db):
|
||||
def test_can_create_user_via_api(preferences, client, db):
|
||||
url = reverse('rest_register')
|
||||
data = {
|
||||
'username': 'test1',
|
||||
|
@ -14,7 +14,7 @@ def test_can_create_user_via_api(settings, client, db):
|
|||
'password1': 'testtest',
|
||||
'password2': 'testtest',
|
||||
}
|
||||
settings.REGISTRATION_MODE = "public"
|
||||
preferences['users__registration_enabled'] = True
|
||||
response = client.post(url, data)
|
||||
assert response.status_code == 201
|
||||
|
||||
|
@ -22,7 +22,7 @@ def test_can_create_user_via_api(settings, client, db):
|
|||
assert u.username == 'test1'
|
||||
|
||||
|
||||
def test_can_disable_registration_view(settings, client, db):
|
||||
def test_can_disable_registration_view(preferences, client, db):
|
||||
url = reverse('rest_register')
|
||||
data = {
|
||||
'username': 'test1',
|
||||
|
@ -30,7 +30,7 @@ def test_can_disable_registration_view(settings, client, db):
|
|||
'password1': 'testtest',
|
||||
'password2': 'testtest',
|
||||
}
|
||||
settings.REGISTRATION_MODE = "disabled"
|
||||
preferences['users__registration_enabled'] = False
|
||||
response = client.post(url, data)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
|
|
@ -74,11 +74,6 @@ DJANGO_SECRET_KEY=
|
|||
# If True, unauthenticated users won't be able to query the API
|
||||
API_AUTHENTICATION_REQUIRED=True
|
||||
|
||||
# What is the workflow for registration on funkwhale ? Possible values:
|
||||
# public: anybody can register an account
|
||||
# disabled: nobody can register an account
|
||||
REGISTRATION_MODE=disabled
|
||||
|
||||
# Sentry/Raven error reporting (server side)
|
||||
# Enable Raven if you want to help improve funkwhale by
|
||||
# automatically sending error reports our Sentry instance.
|
||||
|
|
|
@ -39,6 +39,15 @@ server {
|
|||
|
||||
root /srv/funkwhale/front/dist;
|
||||
|
||||
# global proxy conf
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_redirect off;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ @rewrites;
|
||||
}
|
||||
|
@ -49,15 +58,9 @@ server {
|
|||
location /api/ {
|
||||
# this is needed if you have file import via upload enabled
|
||||
client_max_body_size 30M;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://funkwhale-api/api/;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /srv/funkwhale/data/media/;
|
||||
}
|
||||
|
@ -70,6 +73,41 @@ server {
|
|||
alias /srv/funkwhale/data/media;
|
||||
}
|
||||
|
||||
# Transcoding logic and caching
|
||||
location = /transcode-auth {
|
||||
# needed so we can authenticate transcode requests, but still
|
||||
# cache the result
|
||||
internal;
|
||||
set $query '';
|
||||
# ensure we actually pass the jwt to the underlytin auth url
|
||||
if ($request_uri ~* "[^\?]+\?(.*)$") {
|
||||
set $query $1;
|
||||
}
|
||||
proxy_set_header X-Forwarded-Host $host:$server_port;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
}
|
||||
|
||||
location /api/v1/trackfiles/transcode/ {
|
||||
# this block deals with authenticating and caching transcoding
|
||||
# requests. Caching is heavily recommended as transcoding
|
||||
# is a CPU intensive process.
|
||||
auth_request /transcode-auth;
|
||||
if ($args ~ (.*)jwt=[^&]*(.*)) {
|
||||
set $cleaned_args $1$2;
|
||||
}
|
||||
proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args";
|
||||
proxy_cache transcode;
|
||||
proxy_cache_valid 200 7d;
|
||||
proxy_ignore_headers "Set-Cookie";
|
||||
proxy_hide_header "Set-Cookie";
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
proxy_pass http://funkwhale-api;
|
||||
}
|
||||
# end of transcoding logic
|
||||
|
||||
location /staticfiles/ {
|
||||
# django static files
|
||||
alias /srv/funkwhale/data/static/;
|
||||
|
|
|
@ -26,23 +26,59 @@ http {
|
|||
keepalive_timeout 65;
|
||||
|
||||
#gzip on;
|
||||
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 6001;
|
||||
charset utf-8;
|
||||
client_max_body_size 20M;
|
||||
|
||||
# global proxy pass config
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host localhost:8080;
|
||||
proxy_set_header X-Forwarded-Port 8080;
|
||||
proxy_redirect off;
|
||||
|
||||
location /_protected/media {
|
||||
internal;
|
||||
alias /protected/media;
|
||||
}
|
||||
location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
location = /transcode-auth {
|
||||
# needed so we can authenticate transcode requests, but still
|
||||
# cache the result
|
||||
internal;
|
||||
set $query '';
|
||||
# ensure we actually pass the jwt to the underlytin auth url
|
||||
if ($request_uri ~* "[^\?]+\?(.*)$") {
|
||||
set $query $1;
|
||||
}
|
||||
proxy_set_header X-Forwarded-Host localhost:8080;
|
||||
proxy_set_header X-Forwarded-Port 8080;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://api:12081/api/v1/trackfiles/viewable/?$query;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
}
|
||||
|
||||
location /api/v1/trackfiles/transcode/ {
|
||||
# this block deals with authenticating and caching transcoding
|
||||
# requests. Caching is heavily recommended as transcoding
|
||||
# is a CPU intensive process.
|
||||
auth_request /transcode-auth;
|
||||
if ($args ~ (.*)jwt=[^&]*(.*)) {
|
||||
set $cleaned_args $1$2;
|
||||
}
|
||||
proxy_cache_key "$scheme$request_method$host$uri$is_args$cleaned_args";
|
||||
proxy_cache transcode;
|
||||
proxy_cache_valid 200 7d;
|
||||
proxy_ignore_headers "Set-Cookie";
|
||||
proxy_hide_header "Set-Cookie";
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
proxy_pass http://api:12081;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://api:12081/;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,9 +20,11 @@
|
|||
"js-logger": "^1.3.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.4",
|
||||
"moment": "^2.20.1",
|
||||
"moxios": "^0.4.0",
|
||||
"raven-js": "^3.22.3",
|
||||
"semantic-ui-css": "^2.2.10",
|
||||
"showdown": "^1.8.6",
|
||||
"vue": "^2.3.3",
|
||||
"vue-lazyload": "^1.1.4",
|
||||
"vue-router": "^2.3.1",
|
||||
|
|
|
@ -9,6 +9,9 @@
|
|||
<div class="three wide column">
|
||||
<h4 class="ui header">Links</h4>
|
||||
<div class="ui link list">
|
||||
<router-link class="item" to="/about">
|
||||
About this instance
|
||||
</router-link>
|
||||
<a href="https://funkwhale.audio" class="item" target="_blank">Official website</a>
|
||||
<a href="https://docs.funkwhale.audio" class="item" target="_blank">Documentation</a>
|
||||
<a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">Source code</a>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export default {
|
||||
formats: [
|
||||
// 'audio/ogg',
|
||||
'audio/mpeg'
|
||||
],
|
||||
formatsMap: {
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/mpeg': 'mp3'
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical center aligned stripe segment">
|
||||
<div class="ui text container">
|
||||
<h1 class="ui huge header">
|
||||
<template v-if="instance.name.value">About {{ instance.name.value }}</template>
|
||||
<template v-else="instance.name.value">About this instance</template>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<p v-if="!instance.short_description.value && !instance.long_description.value">
|
||||
Unfortunately, owners of this instance did not yet take the time to complete this page.</p>
|
||||
<div
|
||||
v-if="instance.short_description.value"
|
||||
class="ui middle aligned stackable text container">
|
||||
<p>{{ instance.short_description.value }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="instance.long_description.value"
|
||||
class="ui middle aligned stackable text container"
|
||||
v-html="$options.filters.markdown(instance.long_description.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
export default {
|
||||
created () {
|
||||
this.$store.dispatch('instance/fetchSettings')
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
instance: state => state.instance.settings.instance
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -6,6 +6,10 @@
|
|||
Welcome on funkwhale
|
||||
</h1>
|
||||
<p>We think listening music should be simple.</p>
|
||||
<router-link class="ui icon button" to="/about">
|
||||
<i class="info icon"></i>
|
||||
Learn more about this instance
|
||||
</router-link>
|
||||
<router-link class="ui icon teal button" to="/library">
|
||||
Get me to the library
|
||||
<i class="right arrow icon"></i>
|
||||
|
|
|
@ -49,4 +49,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ui.menu {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -22,7 +22,6 @@ export default {
|
|||
Raven.uninstall()
|
||||
logger.default.info('Installing raven...')
|
||||
Raven.config(this.dsn).addPlugin(RavenVue, Vue).install()
|
||||
console.log({}.test.test)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div :class="['ui', {'tiny': discrete}, 'buttons']">
|
||||
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, 'button']">
|
||||
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, {disabled: playableTracks.length === 0}, 'button']">
|
||||
<i class="ui play icon"></i>
|
||||
<template v-if="!discrete"><slot>Play</slot></template>
|
||||
</button>
|
||||
|
@ -36,20 +36,25 @@ export default {
|
|||
jQuery(this.$el).find('.ui.dropdown').dropdown()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
playableTracks () {
|
||||
let tracks
|
||||
if (this.track) {
|
||||
tracks = [this.track]
|
||||
} else {
|
||||
tracks = this.tracks
|
||||
}
|
||||
return tracks.filter(e => {
|
||||
return e.files.length > 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
add () {
|
||||
if (this.track) {
|
||||
this.$store.dispatch('queue/append', {track: this.track})
|
||||
} else {
|
||||
this.$store.dispatch('queue/appendMany', {tracks: this.tracks})
|
||||
}
|
||||
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks})
|
||||
},
|
||||
addNext (next) {
|
||||
if (this.track) {
|
||||
this.$store.dispatch('queue/append', {track: this.track, index: this.$store.state.queue.currentIndex + 1})
|
||||
} else {
|
||||
this.$store.dispatch('queue/appendMany', {tracks: this.tracks, index: this.$store.state.queue.currentIndex + 1})
|
||||
}
|
||||
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1})
|
||||
if (next) {
|
||||
this.$store.dispatch('queue/next')
|
||||
}
|
||||
|
|
|
@ -232,7 +232,7 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
currentTrack (newValue) {
|
||||
if (!newValue) {
|
||||
if (!newValue || !newValue.album.cover) {
|
||||
this.ambiantColors = this.defaultAmbiantColors
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,21 +1,20 @@
|
|||
<template>
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="url"
|
||||
@error="errored"
|
||||
@progress="updateLoad"
|
||||
@loadeddata="loaded"
|
||||
@durationchange="updateDuration"
|
||||
@timeupdate="updateProgress"
|
||||
@ended="ended"
|
||||
preload>
|
||||
|
||||
<source v-for="src in srcs" :src="src.url" :type="src.type">
|
||||
</audio>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
import backend from '@/audio/backend'
|
||||
import url from '@/utils/url'
|
||||
import formats from '@/audio/formats'
|
||||
|
||||
// import logger from '@/logging'
|
||||
|
||||
|
@ -34,31 +33,43 @@ export default {
|
|||
volume: state => state.player.volume,
|
||||
looping: state => state.player.looping
|
||||
}),
|
||||
url: function () {
|
||||
srcs: function () {
|
||||
let file = this.track.files[0]
|
||||
if (!file) {
|
||||
this.$store.dispatch('player/trackErrored')
|
||||
return null
|
||||
return []
|
||||
}
|
||||
let path = backend.absoluteUrl(file.path)
|
||||
let sources = [
|
||||
{type: file.mimetype, url: file.path}
|
||||
]
|
||||
formats.formats.forEach(f => {
|
||||
if (f !== file.mimetype) {
|
||||
let format = formats.formatsMap[f]
|
||||
let url = `/api/v1/trackfiles/transcode/?track_file=${file.id}&to=${format}`
|
||||
sources.push({type: f, url: url})
|
||||
}
|
||||
})
|
||||
if (this.$store.state.auth.authenticated) {
|
||||
// we need to send the token directly in url
|
||||
// so authentication can be checked by the backend
|
||||
// because for audio files we cannot use the regular Authentication
|
||||
// header
|
||||
path = url.updateQueryString(path, 'jwt', this.$store.state.auth.token)
|
||||
sources.forEach(e => {
|
||||
e.url = url.updateQueryString(e.url, 'jwt', this.$store.state.auth.token)
|
||||
})
|
||||
}
|
||||
return path
|
||||
return sources
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
errored: function () {
|
||||
this.$store.dispatch('player/trackErrored')
|
||||
},
|
||||
updateLoad: function () {
|
||||
|
||||
updateDuration: function (e) {
|
||||
this.$store.commit('player/duration', this.$refs.audio.duration)
|
||||
},
|
||||
loaded: function () {
|
||||
this.$refs.audio.volume = this.volume
|
||||
if (this.isCurrent) {
|
||||
this.$store.commit('player/duration', this.$refs.audio.duration)
|
||||
if (this.startTime) {
|
||||
|
|
|
@ -12,13 +12,13 @@
|
|||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<label>Username or email</label>
|
||||
<input
|
||||
ref="username"
|
||||
required
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="Enter your username"
|
||||
placeholder="Enter your username or email"
|
||||
v-model="credentials.username"
|
||||
>
|
||||
</div>
|
||||
|
@ -32,6 +32,9 @@
|
|||
>
|
||||
</div>
|
||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Login</button>
|
||||
<router-link class="ui right floated basic button" :to="{path: '/signup'}">
|
||||
Create an account
|
||||
</router-link>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -73,9 +76,9 @@ export default {
|
|||
// to properly make use of http in the auth service
|
||||
this.$store.dispatch('auth/login', {
|
||||
credentials,
|
||||
next: this.next,
|
||||
onError: response => {
|
||||
if (response.status === 400) {
|
||||
next: '/library',
|
||||
onError: error => {
|
||||
if (error.response.status === 400) {
|
||||
self.error = 'invalid_credentials'
|
||||
} else {
|
||||
self.error = 'unknown_error'
|
||||
|
|
|
@ -37,7 +37,6 @@
|
|||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import config from '@/config'
|
||||
import logger from '@/logging'
|
||||
|
||||
export default {
|
||||
|
@ -61,12 +60,16 @@ export default {
|
|||
new_password1: this.new_password,
|
||||
new_password2: this.new_password
|
||||
}
|
||||
let url = config.BACKEND_URL + 'api/auth/registration/change-password/'
|
||||
let url = 'auth/registration/change-password/'
|
||||
return axios.post(url, credentials).then(response => {
|
||||
logger.default.info('Password successfully changed')
|
||||
self.$router.push('/profile/me')
|
||||
}, response => {
|
||||
if (response.status === 400) {
|
||||
self.$router.push({
|
||||
name: 'profile',
|
||||
params: {
|
||||
username: self.$store.state.auth.username
|
||||
}})
|
||||
}, error => {
|
||||
if (error.response.status === 400) {
|
||||
self.error = 'invalid_credentials'
|
||||
} else {
|
||||
self.error = 'unknown_error'
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
<template>
|
||||
<div class="main pusher">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>Create a funkwhale account</h2>
|
||||
<form
|
||||
v-if="$store.state.instance.settings.users.registration_enabled.value"
|
||||
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
|
||||
@submit.prevent="submit()">
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">We cannot create your account</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<input
|
||||
ref="username"
|
||||
required
|
||||
type="text"
|
||||
autofocus
|
||||
placeholder="Enter your username"
|
||||
v-model="username">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Email</label>
|
||||
<input
|
||||
ref="email"
|
||||
required
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
v-model="email">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Password</label>
|
||||
<div class="ui action input">
|
||||
<input
|
||||
required
|
||||
:type="passwordInputType"
|
||||
placeholder="Enter your password"
|
||||
v-model="password">
|
||||
<span @click="showPassword = !showPassword" title="Show/hide password" class="ui icon button">
|
||||
<i class="eye icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit">Create my account</button>
|
||||
</form>
|
||||
<p v-else>Registration is currently disabled on this instance, please try again later.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
|
||||
export default {
|
||||
name: 'login',
|
||||
props: {
|
||||
next: {type: String, default: '/'}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
isLoadingInstanceSetting: true,
|
||||
errors: [],
|
||||
isLoading: false,
|
||||
showPassword: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
let self = this
|
||||
this.$store.dispatch('instance/fetchSettings', {
|
||||
callback: function () {
|
||||
self.isLoadingInstanceSetting = false
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
var self = this
|
||||
self.isLoading = true
|
||||
this.errors = []
|
||||
var payload = {
|
||||
username: this.username,
|
||||
password1: this.password,
|
||||
password2: this.password,
|
||||
email: this.email
|
||||
}
|
||||
return axios.post('auth/registration/', payload).then(response => {
|
||||
logger.default.info('Successfully created account')
|
||||
self.$router.push({
|
||||
name: 'profile',
|
||||
params: {
|
||||
username: this.username
|
||||
}})
|
||||
}, error => {
|
||||
self.errors = this.getErrors(error.response)
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
getErrors (response) {
|
||||
let errors = []
|
||||
if (response.status !== 400) {
|
||||
errors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
|
||||
return errors
|
||||
}
|
||||
for (var field in response.data) {
|
||||
if (response.data.hasOwnProperty(field)) {
|
||||
response.data[field].forEach(e => {
|
||||
errors.push(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
passwordInputType () {
|
||||
if (this.showPassword) {
|
||||
return 'text'
|
||||
}
|
||||
return 'password'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<time :datetime="date" :title="date | moment">{{ date | ago }}</time>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ['date']
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,49 @@
|
|||
<template>
|
||||
<div class="comment">
|
||||
<div class="content">
|
||||
<a class="author">{{ user.username }}</a>
|
||||
<div class="metadata">
|
||||
<div class="date"><human-date :date="date"></human-date></div>
|
||||
</div>
|
||||
<div class="text" v-html="comment"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span
|
||||
@click="collapsed = false"
|
||||
v-if="truncated && collapsed"
|
||||
class="expand">Expand</span>
|
||||
<span
|
||||
@click="collapsed = true"
|
||||
v-if="truncated && !collapsed"
|
||||
class="collapse">Collapse</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
user: {type: Object, required: true},
|
||||
date: {required: true},
|
||||
content: {type: String, required: true}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
collapsed: true,
|
||||
length: 50
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
comment () {
|
||||
let text = this.content
|
||||
if (this.collapsed) {
|
||||
text = this.$options.filters.truncate(text, this.length)
|
||||
}
|
||||
return this.$options.filters.markdown(text)
|
||||
},
|
||||
truncated () {
|
||||
return this.content.length > this.length
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,7 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
import HumanDate from '@/components/common/HumanDate'
|
||||
|
||||
Vue.component('human-date', HumanDate)
|
||||
|
||||
export default {}
|
|
@ -4,7 +4,7 @@
|
|||
<search :autofocus="true"></search>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui stackable two column grid">
|
||||
<div class="ui stackable three column grid">
|
||||
<div class="column">
|
||||
<h2 class="ui header">Latest artists</h2>
|
||||
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
|
||||
|
@ -18,6 +18,10 @@
|
|||
<radio-card :type="'random'"></radio-card>
|
||||
<radio-card :type="'less-listened'"></radio-card>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h2 class="ui header">Music requests</h2>
|
||||
<request-form></request-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -30,6 +34,7 @@ import backend from '@/audio/backend'
|
|||
import logger from '@/logging'
|
||||
import ArtistCard from '@/components/audio/artist/Card'
|
||||
import RadioCard from '@/components/radios/Card'
|
||||
import RequestForm from '@/components/requests/Form'
|
||||
|
||||
const ARTISTS_URL = 'artists/'
|
||||
|
||||
|
@ -38,7 +43,8 @@ export default {
|
|||
components: {
|
||||
Search,
|
||||
ArtistCard,
|
||||
RadioCard
|
||||
RadioCard,
|
||||
RequestForm
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
@ -5,8 +5,13 @@
|
|||
<router-link class="ui item" to="/library/artists" exact>Artists</router-link>
|
||||
<router-link class="ui item" to="/library/radios" exact>Radios</router-link>
|
||||
<div class="ui secondary right menu">
|
||||
<router-link class="ui item" to="/library/requests/" exact>
|
||||
Requests
|
||||
<div class="ui teal label">{{ requestsCount }}</div>
|
||||
</router-link>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/launch" exact>Import</router-link>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches</router-link>
|
||||
<router-link v-if="$store.state.auth.availablePermissions['import.launch']" class="ui item" to="/library/import/batches">Import batches
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<router-view :key="$route.fullPath"></router-view>
|
||||
|
@ -14,9 +19,25 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
name: 'library'
|
||||
name: 'library',
|
||||
data () {
|
||||
return {
|
||||
requestsCount: 0
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchRequestsCount()
|
||||
},
|
||||
methods: {
|
||||
fetchRequestsCount () {
|
||||
let self = this
|
||||
axios.get('requests/import-requests', {params: {status: 'pending'}}).then(response => {
|
||||
self.requestsCount = response.data.count
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ export default {
|
|||
defaultEnabled: {type: Boolean, default: true},
|
||||
backends: {type: Array},
|
||||
defaultBackendId: {type: String},
|
||||
queryTemplate: {type: String, default: '$artist $title'}
|
||||
queryTemplate: {type: String, default: '$artist $title'},
|
||||
request: {type: Object, required: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
@ -32,6 +33,9 @@ export default {
|
|||
this.isImporting = true
|
||||
let url = 'submit/' + self.importType + '/'
|
||||
let payload = self.importData
|
||||
if (this.request) {
|
||||
payload.importRequest = this.request.id
|
||||
}
|
||||
axios.post(url, payload).then((response) => {
|
||||
logger.default.info('launched import for', self.type, self.metadata.id)
|
||||
self.isImporting = false
|
||||
|
|
|
@ -92,6 +92,7 @@
|
|||
<component
|
||||
ref="import"
|
||||
v-if="currentSource == 'external'"
|
||||
:request="currentRequest"
|
||||
:metadata="metadata"
|
||||
:is="importComponent"
|
||||
:backends="backends"
|
||||
|
@ -113,7 +114,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui vertical stripe segment" v-if="currentRequest">
|
||||
<h3 class="ui header">Music request</h3>
|
||||
<p>This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.</p>
|
||||
<request-card :request="currentRequest" :import-action="false"></request-card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
@ -121,6 +125,7 @@
|
|||
|
||||
<script>
|
||||
|
||||
import RequestCard from '@/components/requests/Card'
|
||||
import MetadataSearch from '@/components/metadata/Search'
|
||||
import ReleaseCard from '@/components/metadata/ReleaseCard'
|
||||
import ArtistCard from '@/components/metadata/ArtistCard'
|
||||
|
@ -128,6 +133,7 @@ import ReleaseImport from './ReleaseImport'
|
|||
import FileUpload from './FileUpload'
|
||||
import ArtistImport from './ArtistImport'
|
||||
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
import $ from 'jquery'
|
||||
|
||||
|
@ -138,19 +144,22 @@ export default {
|
|||
ReleaseCard,
|
||||
ArtistImport,
|
||||
ReleaseImport,
|
||||
FileUpload
|
||||
FileUpload,
|
||||
RequestCard
|
||||
},
|
||||
props: {
|
||||
mbType: {type: String, required: false},
|
||||
request: {type: String, required: false},
|
||||
source: {type: String, required: false},
|
||||
mbId: {type: String, required: false}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
currentRequest: null,
|
||||
currentType: this.mbType || 'artist',
|
||||
currentId: this.mbId,
|
||||
currentStep: 0,
|
||||
currentSource: '',
|
||||
currentSource: this.source,
|
||||
metadata: {},
|
||||
isImporting: false,
|
||||
importData: {
|
||||
|
@ -166,6 +175,9 @@ export default {
|
|||
}
|
||||
},
|
||||
created () {
|
||||
if (this.request) {
|
||||
this.fetchRequest(this.request)
|
||||
}
|
||||
if (this.currentSource) {
|
||||
this.currentStep = 1
|
||||
}
|
||||
|
@ -179,7 +191,8 @@ export default {
|
|||
query: {
|
||||
source: this.currentSource,
|
||||
type: this.currentType,
|
||||
id: this.currentId
|
||||
id: this.currentId,
|
||||
request: this.request
|
||||
}
|
||||
})
|
||||
},
|
||||
|
@ -197,6 +210,12 @@ export default {
|
|||
},
|
||||
updateId (newValue) {
|
||||
this.currentId = newValue
|
||||
},
|
||||
fetchRequest (id) {
|
||||
let self = this
|
||||
axios.get(`requests/import-requests/${id}`).then((response) => {
|
||||
self.currentRequest = response.data
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div :class="['ui', {collapsed: collapsed}, 'card']">
|
||||
<div class="content">
|
||||
<div class="header">{{ request.artist_name }}</div>
|
||||
<div class="description">
|
||||
<div
|
||||
v-if="request.albums" v-html="$options.filters.markdown(request.albums)"></div>
|
||||
<div v-if="request.comment" class="ui comments">
|
||||
<comment
|
||||
:user="request.user"
|
||||
:content="request.comment"
|
||||
:date="request.creation_date"></comment>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<span >
|
||||
<i v-if="request.status === 'pending'" class="hourglass start icon"></i>
|
||||
<i v-if="request.status === 'accepted'" class="hourglass half icon"></i>
|
||||
<i v-if="request.status === 'imported'" class="check icon"></i>
|
||||
{{ request.status | capitalize }}
|
||||
</span>
|
||||
<button
|
||||
@click="createImport"
|
||||
v-if="request.status === 'pending' && importAction && $store.state.auth.availablePermissions['import.launch']"
|
||||
class="ui mini basic green right floated button">Create import</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Comment from '@/components/discussion/Comment'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
request: {type: Object, required: true},
|
||||
importAction: {type: Boolean, default: true}
|
||||
},
|
||||
components: {
|
||||
Comment
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
collapsed: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createImport () {
|
||||
this.$router.push({
|
||||
name: 'library.import.launch',
|
||||
query: {request: this.request.id}})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<div>
|
||||
<form v-if="!over" class="ui form" @submit.prevent="submit">
|
||||
<p>Something's missing in the library? Let us know what you would like to listen!</p>
|
||||
<div class="required field">
|
||||
<label>Artist name</label>
|
||||
<input v-model="currentArtistName" placeholder="The Beatles, Mickael Jackson…" required maxlength="200">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Albums</label>
|
||||
<p>Leave this field empty if you're requesting the whole discography.</p>
|
||||
<input v-model="currentAlbums" placeholder="The White Album, Thriller…" maxlength="2000">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Comment</label>
|
||||
<textarea v-model="currentComment" rows="3" placeholder="Use this comment box to add details to your request if needed" maxlength="2000"></textarea>
|
||||
</div>
|
||||
<button class="ui submit button" type="submit">Submit</button>
|
||||
</form>
|
||||
<div v-else class="ui success message">
|
||||
<div class="header">Request submitted!</div>
|
||||
<p>We've received your request, you'll get some groove soon ;)</p>
|
||||
<button @click="reset" class="ui button">Submit another request</button>
|
||||
</div>
|
||||
<div v-if="requests.length > 0">
|
||||
<div class="ui divider"></div>
|
||||
<h3 class="ui header">Pending requests</h3>
|
||||
<div class="ui list">
|
||||
<div v-for="request in requests" class="item">
|
||||
<div class="content">
|
||||
<div class="header">{{ request.artist_name }}</div>
|
||||
<div v-if="request.albums" class="description">
|
||||
{{ request.albums|truncate }}</div>
|
||||
<div v-if="request.comment" class="description">
|
||||
{{ request.comment|truncate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import $ from 'jquery'
|
||||
import axios from 'axios'
|
||||
|
||||
import logger from '@/logging'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
defaultArtistName: {type: String, default: ''},
|
||||
defaultAlbums: {type: String, default: ''},
|
||||
defaultComment: {type: String, default: ''}
|
||||
},
|
||||
created () {
|
||||
this.fetchRequests()
|
||||
},
|
||||
mounted () {
|
||||
$('.ui.radio.checkbox').checkbox()
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
currentArtistName: this.defaultArtistName,
|
||||
currentAlbums: this.defaultAlbums,
|
||||
currentComment: this.defaultComment,
|
||||
isLoading: false,
|
||||
over: false,
|
||||
requests: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchRequests () {
|
||||
let self = this
|
||||
let url = 'requests/import-requests/'
|
||||
axios.get(url, {}).then((response) => {
|
||||
self.requests = response.data.results
|
||||
})
|
||||
},
|
||||
submit () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
let url = 'requests/import-requests/'
|
||||
let payload = {
|
||||
artist_name: this.currentArtistName,
|
||||
albums: this.currentAlbums,
|
||||
comment: this.currentComment
|
||||
}
|
||||
axios.post(url, payload).then((response) => {
|
||||
logger.default.info('Submitted request!')
|
||||
self.isLoading = false
|
||||
self.over = true
|
||||
self.requests.unshift(response.data)
|
||||
}, (response) => {
|
||||
logger.default.error('error while submitting request')
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
reset () {
|
||||
this.over = false
|
||||
this.currentArtistName = ''
|
||||
this.currentAlbums = ''
|
||||
this.currentComment = ''
|
||||
},
|
||||
truncate (string, length) {
|
||||
if (string.length > length) {
|
||||
return string.substring(0, length) + '…'
|
||||
}
|
||||
return string
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,163 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">Music requests</h2>
|
||||
<div :class="['ui', {'loading': isLoading}, 'form']">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label>Search</label>
|
||||
<input type="text" v-model="query" placeholder="Enter an artist name, a username..."/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ordering</label>
|
||||
<select class="ui dropdown" v-model="ordering">
|
||||
<option v-for="option in orderingOptions" :value="option[0]">
|
||||
{{ option[1] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ordering direction</label>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value="">Ascending</option>
|
||||
<option value="-">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Results per page</label>
|
||||
<select class="ui dropdown" v-model="paginateBy">
|
||||
<option :value="parseInt(12)">12</option>
|
||||
<option :value="parseInt(25)">25</option>
|
||||
<option :value="parseInt(50)">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div v-if="result" class="ui stackable three column grid">
|
||||
<div
|
||||
v-if="result.results.length > 0"
|
||||
v-for="request in result.results"
|
||||
:key="request.id"
|
||||
class="column">
|
||||
<request-card class="fluid" :request="request"></request-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui center aligned basic segment">
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import $ from 'jquery'
|
||||
|
||||
import logger from '@/logging'
|
||||
|
||||
import OrderingMixin from '@/components/mixins/Ordering'
|
||||
import PaginationMixin from '@/components/mixins/Pagination'
|
||||
import RequestCard from '@/components/requests/Card'
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
const FETCH_URL = 'requests/import-requests/'
|
||||
|
||||
export default {
|
||||
mixins: [OrderingMixin, PaginationMixin],
|
||||
props: {
|
||||
defaultQuery: {type: String, required: false, default: ''}
|
||||
},
|
||||
components: {
|
||||
RequestCard,
|
||||
Pagination
|
||||
},
|
||||
data () {
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||
return {
|
||||
isLoading: true,
|
||||
result: null,
|
||||
page: parseInt(this.defaultPage),
|
||||
query: this.defaultQuery,
|
||||
paginateBy: parseInt(this.defaultPaginateBy || 12),
|
||||
orderingDirection: defaultOrdering.direction,
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'Creation date'],
|
||||
['artist_name', 'Artist name'],
|
||||
['user__username', 'User']
|
||||
]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
mounted () {
|
||||
$('.ui.dropdown').dropdown()
|
||||
},
|
||||
methods: {
|
||||
updateQueryString: _.debounce(function () {
|
||||
this.$router.replace({
|
||||
query: {
|
||||
query: this.query,
|
||||
page: this.page,
|
||||
paginateBy: this.paginateBy,
|
||||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
})
|
||||
}, 500),
|
||||
fetchData: _.debounce(function () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL
|
||||
let params = {
|
||||
page: this.page,
|
||||
page_size: this.paginateBy,
|
||||
search: this.query,
|
||||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
logger.default.debug('Fetching request...')
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.result = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
}, 500),
|
||||
selectPage: function (page) {
|
||||
this.page = page
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
page () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
paginateBy () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
ordering () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
orderingDirection () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
query () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,44 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
import moment from 'moment'
|
||||
import showdown from 'showdown'
|
||||
|
||||
export function truncate (str, max, ellipsis) {
|
||||
max = max || 100
|
||||
ellipsis = ellipsis || '…'
|
||||
if (str.length <= max) {
|
||||
return str
|
||||
}
|
||||
return str.slice(0, max) + ellipsis
|
||||
}
|
||||
|
||||
Vue.filter('truncate', truncate)
|
||||
|
||||
export function markdown (str) {
|
||||
const converter = new showdown.Converter()
|
||||
return converter.makeHtml(str)
|
||||
}
|
||||
|
||||
Vue.filter('markdown', markdown)
|
||||
|
||||
export function ago (date) {
|
||||
const m = moment(date)
|
||||
return m.fromNow()
|
||||
}
|
||||
|
||||
Vue.filter('ago', ago)
|
||||
|
||||
export function momentFormat (date, format) {
|
||||
format = format || 'lll'
|
||||
return moment(date).format(format)
|
||||
}
|
||||
|
||||
Vue.filter('moment', momentFormat)
|
||||
|
||||
export function capitalize (str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
Vue.filter('capitalize', capitalize)
|
||||
|
||||
export default {}
|
|
@ -13,6 +13,8 @@ import VueLazyload from 'vue-lazyload'
|
|||
import store from './store'
|
||||
import config from './config'
|
||||
import { sync } from 'vuex-router-sync'
|
||||
import filters from '@/filters' // eslint-disable-line
|
||||
import globals from '@/components/globals' // eslint-disable-line
|
||||
|
||||
sync(store, router)
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import PageNotFound from '@/components/PageNotFound'
|
||||
import About from '@/components/About'
|
||||
import Home from '@/components/Home'
|
||||
import Login from '@/components/auth/Login'
|
||||
import Signup from '@/components/auth/Signup'
|
||||
import Profile from '@/components/auth/Profile'
|
||||
import Settings from '@/components/auth/Settings'
|
||||
import Logout from '@/components/auth/Logout'
|
||||
|
@ -17,6 +19,7 @@ import LibraryRadios from '@/components/library/Radios'
|
|||
import RadioBuilder from '@/components/library/radios/Builder'
|
||||
import BatchList from '@/components/library/import/BatchList'
|
||||
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||
import RequestsList from '@/components/requests/RequestsList'
|
||||
|
||||
import Favorites from '@/components/favorites/List'
|
||||
|
||||
|
@ -31,12 +34,22 @@ export default new Router({
|
|||
name: 'index',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: About
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
props: (route) => ({ next: route.query.next || '/library' })
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
name: 'signup',
|
||||
component: Signup
|
||||
},
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
|
@ -98,7 +111,11 @@ export default new Router({
|
|||
path: 'import/launch',
|
||||
name: 'library.import.launch',
|
||||
component: LibraryImport,
|
||||
props: (route) => ({ mbType: route.query.type, mbId: route.query.id })
|
||||
props: (route) => ({
|
||||
source: route.query.source,
|
||||
request: route.query.request,
|
||||
mbType: route.query.type,
|
||||
mbId: route.query.id })
|
||||
},
|
||||
{
|
||||
path: 'import/batches',
|
||||
|
@ -107,7 +124,21 @@ export default new Router({
|
|||
children: [
|
||||
]
|
||||
},
|
||||
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
|
||||
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true },
|
||||
{
|
||||
path: 'requests/',
|
||||
name: 'library.requests',
|
||||
component: RequestsList,
|
||||
props: (route) => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page,
|
||||
defaultStatus: route.query.status || 'pending'
|
||||
}),
|
||||
children: [
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ path: '*', component: PageNotFound }
|
||||
|
|
|
@ -6,6 +6,22 @@ export default {
|
|||
namespaced: true,
|
||||
state: {
|
||||
settings: {
|
||||
instance: {
|
||||
name: {
|
||||
value: ''
|
||||
},
|
||||
short_description: {
|
||||
value: ''
|
||||
},
|
||||
long_description: {
|
||||
value: ''
|
||||
}
|
||||
},
|
||||
users: {
|
||||
registration_enabled: {
|
||||
value: true
|
||||
}
|
||||
},
|
||||
raven: {
|
||||
front_enabled: {
|
||||
value: false
|
||||
|
@ -23,7 +39,7 @@ export default {
|
|||
},
|
||||
actions: {
|
||||
// Send a request to the login URL and save the returned JWT
|
||||
fetchSettings ({commit}) {
|
||||
fetchSettings ({commit}, payload) {
|
||||
return axios.get('instance/settings/').then(response => {
|
||||
logger.default.info('Successfully fetched instance settings')
|
||||
let sections = {}
|
||||
|
@ -34,6 +50,9 @@ export default {
|
|||
sections[e.section][e.name] = e
|
||||
})
|
||||
commit('settings', sections)
|
||||
if (payload && payload.callback) {
|
||||
payload.callback()
|
||||
}
|
||||
}, response => {
|
||||
logger.default.error('Error while fetching settings', response.data)
|
||||
})
|
||||
|
|
|
@ -50,7 +50,12 @@ export default {
|
|||
},
|
||||
getters: {
|
||||
durationFormatted: state => {
|
||||
return time.parse(Math.round(state.duration))
|
||||
let duration = parseInt(state.duration)
|
||||
if (duration % 1 !== 0) {
|
||||
return time.parse(0)
|
||||
}
|
||||
duration = Math.round(state.duration)
|
||||
return time.parse(duration)
|
||||
},
|
||||
currentTimeFormatted: state => {
|
||||
return time.parse(Math.round(state.currentTime))
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import {truncate, markdown, ago, capitalize} from '@/filters'
|
||||
|
||||
describe('filters', () => {
|
||||
describe('truncate', () => {
|
||||
it('leave strings as it if correct size', () => {
|
||||
const input = 'Hello world'
|
||||
let output = truncate(input, 100)
|
||||
expect(output).to.equal(input)
|
||||
})
|
||||
it('returns shorter string with character', () => {
|
||||
const input = 'Hello world'
|
||||
let output = truncate(input, 5)
|
||||
expect(output).to.equal('Hello…')
|
||||
})
|
||||
it('custom ellipsis', () => {
|
||||
const input = 'Hello world'
|
||||
let output = truncate(input, 5, ' pouet')
|
||||
expect(output).to.equal('Hello pouet')
|
||||
})
|
||||
})
|
||||
describe('markdown', () => {
|
||||
it('renders markdown', () => {
|
||||
const input = 'Hello world'
|
||||
let output = markdown(input)
|
||||
expect(output).to.equal('<p>Hello world</p>')
|
||||
})
|
||||
})
|
||||
describe('ago', () => {
|
||||
it('works', () => {
|
||||
const input = new Date()
|
||||
let output = ago(input)
|
||||
expect(output).to.equal('a few seconds ago')
|
||||
})
|
||||
})
|
||||
describe('capitalize', () => {
|
||||
it('works', () => {
|
||||
const input = 'hello world'
|
||||
let output = capitalize(input)
|
||||
expect(output).to.equal('Hello world')
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue