diff --git a/.gitignore b/.gitignore index c1b8300f2..66ec5a41d 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,7 @@ api/music api/media api/staticfiles api/static - +api/.pytest_cache # Front front/node_modules/ diff --git a/CHANGELOG b/CHANGELOG index 2d005e1a3..5572a45ea 100644 --- a/CHANGELOG +++ b/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) ---------------- diff --git a/api/Dockerfile b/api/Dockerfile index 3281e6f56..5d4e85857 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -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 diff --git a/api/config/api_urls.py b/api/config/api_urls.py index c7ebc4ed3..ff6db0d06 100644 --- a/api/config/api_urls.py +++ b/api/config/api_urls.py @@ -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'), ] diff --git a/api/config/settings/common.py b/api/config/settings/common.py index 6d02cbbc1..491babdd1 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -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', diff --git a/api/config/urls.py b/api/config/urls.py index de67ebb57..8f7e37bc2 100644 --- a/api/config/urls.py +++ b/api/config/urls.py @@ -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 diff --git a/api/docker/Dockerfile.test b/api/docker/Dockerfile.test index 08b437cf2..069b89c2f 100644 --- a/api/docker/Dockerfile.test +++ b/api/docker/Dockerfile.test @@ -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 diff --git a/api/funkwhale_api/__init__.py b/api/funkwhale_api/__init__.py index d1c7fcdf4..7675f9251 100644 --- a/api/funkwhale_api/__init__.py +++ b/api/funkwhale_api/__init__.py @@ -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('.')]) diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py index 32bad6060..59dcbd26b 100644 --- a/api/funkwhale_api/history/views.py +++ b/api/funkwhale_api/history/views.py @@ -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: diff --git a/api/funkwhale_api/instance/dynamic_preferences_registry.py b/api/funkwhale_api/instance/dynamic_preferences_registry.py index 1d93c383e..1d11a2988 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -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 diff --git a/api/funkwhale_api/music/forms.py b/api/funkwhale_api/music/forms.py new file mode 100644 index 000000000..04e4bfe05 --- /dev/null +++ b/api/funkwhale_api/music/forms.py @@ -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() + ) diff --git a/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py new file mode 100644 index 000000000..c45298798 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0018_auto_20180218_1554.py @@ -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), + ), + ] diff --git a/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py new file mode 100644 index 000000000..127aa5e69 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0019_populate_mimetypes.py @@ -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), + ] diff --git a/api/funkwhale_api/music/migrations/0020_importbatch_status.py b/api/funkwhale_api/music/migrations/0020_importbatch_status.py new file mode 100644 index 000000000..265d1ba5d --- /dev/null +++ b/api/funkwhale_api/music/migrations/0020_importbatch_status.py @@ -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), + ), + ] diff --git a/api/funkwhale_api/music/migrations/0021_populate_batch_status.py b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py new file mode 100644 index 000000000..061d649b0 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0021_populate_batch_status.py @@ -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), + ] diff --git a/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py new file mode 100644 index 000000000..d9f6f01d9 --- /dev/null +++ b/api/funkwhale_api/music/migrations/0022_importbatch_import_request.py @@ -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'), + ), + ] diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index f8373ab4d..97992fc8f 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -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']) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 506893a4d..db6298a9e 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -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',) diff --git a/api/funkwhale_api/music/utils.py b/api/funkwhale_api/music/utils.py index 32b1aeb47..a75cf5de8 100644 --- a/api/funkwhale_api/music/utils.py +++ b/api/funkwhale_api/music/utils.py @@ -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' diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 2395454c4..bf9d39b1d 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -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]) diff --git a/api/funkwhale_api/requests/__init__.py b/api/funkwhale_api/requests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/requests/api_urls.py b/api/funkwhale_api/requests/api_urls.py new file mode 100644 index 000000000..37459a664 --- /dev/null +++ b/api/funkwhale_api/requests/api_urls.py @@ -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 diff --git a/api/funkwhale_api/requests/factories.py b/api/funkwhale_api/requests/factories.py new file mode 100644 index 000000000..2bcdeb6a9 --- /dev/null +++ b/api/funkwhale_api/requests/factories.py @@ -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' diff --git a/api/funkwhale_api/requests/filters.py b/api/funkwhale_api/requests/filters.py new file mode 100644 index 000000000..bf353e8ad --- /dev/null +++ b/api/funkwhale_api/requests/filters.py @@ -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'], + } diff --git a/api/funkwhale_api/requests/migrations/0001_initial.py b/api/funkwhale_api/requests/migrations/0001_initial.py new file mode 100644 index 000000000..7c239b3c0 --- /dev/null +++ b/api/funkwhale_api/requests/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/api/funkwhale_api/requests/migrations/__init__.py b/api/funkwhale_api/requests/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py new file mode 100644 index 000000000..c29852430 --- /dev/null +++ b/api/funkwhale_api/requests/models.py @@ -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) diff --git a/api/funkwhale_api/requests/serializers.py b/api/funkwhale_api/requests/serializers.py new file mode 100644 index 000000000..51a709514 --- /dev/null +++ b/api/funkwhale_api/requests/serializers.py @@ -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) diff --git a/api/funkwhale_api/requests/views.py b/api/funkwhale_api/requests/views.py new file mode 100644 index 000000000..395fac66c --- /dev/null +++ b/api/funkwhale_api/requests/views.py @@ -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 diff --git a/api/funkwhale_api/users/adapters.py b/api/funkwhale_api/users/adapters.py index 792b4860f..96d1b8b1d 100644 --- a/api/funkwhale_api/users/adapters.py +++ b/api/funkwhale_api/users/adapters.py @@ -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'] diff --git a/api/funkwhale_api/users/dynamic_preferences_registry.py b/api/funkwhale_api/users/dynamic_preferences_registry.py new file mode 100644 index 000000000..16d79da14 --- /dev/null +++ b/api/funkwhale_api/users/dynamic_preferences_registry.py @@ -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?' + ) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 261873bdb..8c218b1c2 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -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() diff --git a/api/requirements.apt b/api/requirements.apt index e28360b56..462a5a705 100644 --- a/api/requirements.apt +++ b/api/requirements.apt @@ -5,6 +5,7 @@ libjpeg-dev zlib1g-dev libpq-dev postgresql-client -libav-tools +libmagic-dev +ffmpeg python3-dev curl diff --git a/api/requirements/base.txt b/api/requirements/base.txt index f38da9629..133fcc0cb 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -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 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 4d7a6fa98..10d7c3235 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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']() diff --git a/api/tests/instance/test_preferences.py b/api/tests/instance/test_preferences.py index c89bfa349..beb8e6d33 100644 --- a/api/tests/instance/test_preferences.py +++ b/api/tests/instance/test_preferences.py @@ -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 diff --git a/api/tests/music/test_import.py b/api/tests/music/test_import.py new file mode 100644 index 000000000..f2ca1abbd --- /dev/null +++ b/api/tests/music/test_import.py @@ -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 diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 165415465..9f52ba887 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -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 diff --git a/api/tests/requests/test_models.py b/api/tests/requests/test_models.py new file mode 100644 index 000000000..797656bd7 --- /dev/null +++ b/api/tests/requests/test_models.py @@ -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' diff --git a/api/tests/requests/test_views.py b/api/tests/requests/test_views.py new file mode 100644 index 000000000..6c34f9ad1 --- /dev/null +++ b/api/tests/requests/test_views.py @@ -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 diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 1eb8ef222..569acbd15 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -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 diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample index 5bdfeb9c6..6a4b15b67 100644 --- a/deploy/env.prod.sample +++ b/deploy/env.prod.sample @@ -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. diff --git a/deploy/nginx.conf b/deploy/nginx.conf index cf865a9ea..dfdbac2ae 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -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/; diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev index 1b749c30a..29c04fc66 100644 --- a/docker/nginx/conf.dev +++ b/docker/nginx/conf.dev @@ -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/; } } diff --git a/front/package.json b/front/package.json index ac3895f6d..042e332d0 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/App.vue b/front/src/App.vue index 98ad48d3f..8453aa339 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -9,6 +9,9 @@

Links

-
+
+

Music request

+

This import will be associated with the music request below. After the import is finished, the request will be marked as fulfilled.

+
@@ -121,6 +125,7 @@ + + + diff --git a/front/src/components/requests/Form.vue b/front/src/components/requests/Form.vue new file mode 100644 index 000000000..68c725ba7 --- /dev/null +++ b/front/src/components/requests/Form.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue new file mode 100644 index 000000000..cb3e9af00 --- /dev/null +++ b/front/src/components/requests/RequestsList.vue @@ -0,0 +1,163 @@ + + + + + + diff --git a/front/src/filters.js b/front/src/filters.js new file mode 100644 index 000000000..22d93149b --- /dev/null +++ b/front/src/filters.js @@ -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 {} diff --git a/front/src/main.js b/front/src/main.js index d1ff90c32..2e351310a 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -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) diff --git a/front/src/router/index.js b/front/src/router/index.js index 971ef05cd..827afc218 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -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 } diff --git a/front/src/store/instance.js b/front/src/store/instance.js index a0071f096..a4dfcada6 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -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) }) diff --git a/front/src/store/player.js b/front/src/store/player.js index fb348042f..df8d159f4 100644 --- a/front/src/store/player.js +++ b/front/src/store/player.js @@ -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)) diff --git a/front/test/unit/specs/filters/filters.spec.js b/front/test/unit/specs/filters/filters.spec.js new file mode 100644 index 000000000..c2b43da44 --- /dev/null +++ b/front/test/unit/specs/filters/filters.spec.js @@ -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('

Hello world

') + }) + }) + 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') + }) + }) +})