diff --git a/.gitignore b/.gitignore index c1b8300f2989fd0c22b266df093efcd1354367df..66ec5a41dbee681dbbbbe0e61802282c0e629e8e 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 2d005e1a36b0cbca335ee63d9d4ccb70d2381d6e..5572a45ead0c91c605f2a7214a00a0a2f158a50e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,10 +1,44 @@ Changelog ========= +0.6 (Unreleased) +---------------- + -0.5 (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 3281e6f562a64dd7df21d8f2a7186814e3ba985c..5d4e858574a0063b756a168f48b529e904bc427b 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 c7ebc4ed3668196ccd11d6844ba14d704e3ba43e..ff6db0d069395c316d207a640e0187ccf92b12df 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 6d02cbbc1038999cc7a3de46448369aa00e030bf..491babdd15f8d4c017230f097af4b274c880a000 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 de67ebb571de4b5f4e15cedd969d1adadeb42aee..8f7e37bc26ae56ba9967682f4ec3f19f04cc71f4 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 08b437cf25d136579ab04bf50a484cd8bccb8883..069b89c2f83de1628d485374545f908fe52ec93c 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 d1c7fcdf447cc845b96ee86c5de04dbb97805eb6..7675f9251bc44f178c27e0c96b7846a0a5410931 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 32bad6060273e53abad7de942c285b48b16c252e..59dcbd26b8449abef324273219a961d44f39f489 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 1d93c383eb80372c507862a3b4f2e3450d792fca..1d11a2988ca1d76da7b61d8ef8657ac6cdee74df 100644 --- a/api/funkwhale_api/instance/dynamic_preferences_registry.py +++ b/api/funkwhale_api/instance/dynamic_preferences_registry.py @@ -1,8 +1,41 @@ +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): diff --git a/api/funkwhale_api/music/forms.py b/api/funkwhale_api/music/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..04e4bfe057c0723b161265c63000a2494c687a9d --- /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 0000000000000000000000000000000000000000..c45298798b87f52dfe101b1d5f4a10a94407f10b --- /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 0000000000000000000000000000000000000000..127aa5e69a245215e2b0f16e0b5e3081efb7db49 --- /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 0000000000000000000000000000000000000000..265d1ba5d5312d086f25d0861039b3a94a5df4e5 --- /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 0000000000000000000000000000000000000000..061d649b06115d4871b6c6eb7ae57b25c27661e8 --- /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 0000000000000000000000000000000000000000..d9f6f01d9121f1148e1f3d5a729b1fc476a89f42 --- /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 f8373ab4d0f286dae34da01dfbffe8967ed9f9a0..97992fc8f12cafa7af7cb99c7cbfb1cee2b42192 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 506893a4d23ae1026f5f26a159eeb841443dde96..db6298a9e446eddc64bc17f95e3a6b75bf126573 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 32b1aeb470af219124e8a0c533f13b6e991981c5..a75cf5de8508efaa5df587eb17b01a2e87f1214f 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 2395454c46429a5b48ccee7fe22e25bf39808318..bf9d39b1d507c7122cb5e6d2eeb70b45e8946fee 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/requests/api_urls.py b/api/funkwhale_api/requests/api_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..37459a664a4d7e1fd9bc229fbb678daa02adb012 --- /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 0000000000000000000000000000000000000000..2bcdeb6a9699ee47a16cc4fb2e05ebe9b19286e0 --- /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 0000000000000000000000000000000000000000..bf353e8ad075abb76f3a4fdc846d9d25b0a105f4 --- /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 0000000000000000000000000000000000000000..7c239b3c079234ec130be85c300e3a9956a7ad41 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/funkwhale_api/requests/models.py b/api/funkwhale_api/requests/models.py new file mode 100644 index 0000000000000000000000000000000000000000..c298524306caa9bc7f4cd6e9eb1f4abbb2d4bde0 --- /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 0000000000000000000000000000000000000000..51a709514e0cb95a2f03289dd981df918734c795 --- /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 0000000000000000000000000000000000000000..395fac66cff33e3a01fdcc33a56adfcea0aa0c7e --- /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 792b4860fc240439f72c8cd7dc2fe319ac633fa1..96d1b8b1d6b4aaa708bc3c09490beae84c10fa1a 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 0000000000000000000000000000000000000000..16d79da143cb3139f9a92137044b6092288e1b40 --- /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 261873bdbec1a34ee0c69e30a002161e69e423f7..8c218b1c28acd2811b208e0a3f4b4f0d788c1aea 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 e28360b5658fe17d9f2a5afa4d6e28feedf87101..462a5a705c75b319636449490c6e3c29b089a7f1 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 f38da9629041fcd8f9f1a0620d00ec17c0823f32..133fcc0cb65f0deb430d73ff5259d05efc215cc2 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 4d7a6fa981d1431cd01a97d4578d2a992b4cb749..10d7c323512c684ff495bda2a2a7a7ae581213f8 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 c89bfa3492091a93d0000f661bd83f2fc8ebedc0..beb8e6d33f810110c35ed14b164a76ed4c8bb019 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 0000000000000000000000000000000000000000..f2ca1abbd04a562764194f09653e17c4724f3cc4 --- /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 165415465d1431d7294f065efccdb390b87b6b96..9f52ba8874e50c10ea0216cacc8363ab767d6396 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 0000000000000000000000000000000000000000..797656bd70c5ab916c650c589bd70ea5fc41be87 --- /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 0000000000000000000000000000000000000000..6c34f9ad19fcc8ae7ec10ba6563cb44d45cb3461 --- /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 1eb8ef222a79d68f41d40c8555c0c7cb9d931680..569acbd15ee5138150dd7de4112cd7ebd2d5523a 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 5bdfeb9c626fa074eaedb5c7f499871946d3dea9..6a4b15b67cf04857b87296c30a23060e650f2d7c 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 cf865a9ea7b3ef84eeb6eda8d6d65f509790eaf5..dfdbac2ae909bf9d5efafb1698f4fd8256e17eba 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 1b749c30a24006e2e954044bc50ec03a94d1adb6..29c04fc6643c17a8918c9ad4546993d49587336e 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 ac3895f6d65655fd198699c92cd55300df301b1e..042e332d0eb91d81df9c27fe13b82b0e16013d3a 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 98ad48d3ff41cd37cfa4df496c51c24f8dbd0544..8453aa33941813d6c451fa2184d05e7f9c669486 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -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> diff --git a/front/src/audio/formats.js b/front/src/audio/formats.js new file mode 100644 index 0000000000000000000000000000000000000000..f6e2157a15d6c64afff10addd30ceba724dcdc65 --- /dev/null +++ b/front/src/audio/formats.js @@ -0,0 +1,10 @@ +export default { + formats: [ + // 'audio/ogg', + 'audio/mpeg' + ], + formatsMap: { + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3' + } +} diff --git a/front/src/components/About.vue b/front/src/components/About.vue new file mode 100644 index 0000000000000000000000000000000000000000..01ce6a294fdeb632d03e8eb23a4a4bbf83766223 --- /dev/null +++ b/front/src/components/About.vue @@ -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> diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue index dd324943feccf17c8e0a1e9a570c836b984cde7a..0cea1c25ab4361d019d6df6be7c4cdad4b1c43a5 100644 --- a/front/src/components/Home.vue +++ b/front/src/components/Home.vue @@ -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> diff --git a/front/src/components/Pagination.vue b/front/src/components/Pagination.vue index 3ac7c59af5817413c13a97e569065ca48cb95b91..83b386fdeb57f72700b97abacf37c4512bcff5e0 100644 --- a/front/src/components/Pagination.vue +++ b/front/src/components/Pagination.vue @@ -49,4 +49,8 @@ export default { </script> <style scoped> +.ui.menu { + border: none; + box-shadow: none; +} </style> diff --git a/front/src/components/Raven.vue b/front/src/components/Raven.vue index e5e125b810ec0cb53e4080d64f3b0c9133305307..f45d0ed22bee60d746599c1c340695cf00b346a6 100644 --- a/front/src/components/Raven.vue +++ b/front/src/components/Raven.vue @@ -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: { diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 4767255ecae8b6bb27a872614f9a4e029146ded9..451cdcf0188a9e213e4d7cb785e9dd926ff23147 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -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() } }, - methods: { - add () { + computed: { + playableTracks () { + let tracks if (this.track) { - this.$store.dispatch('queue/append', {track: this.track}) + tracks = [this.track] } else { - this.$store.dispatch('queue/appendMany', {tracks: this.tracks}) + tracks = this.tracks } + return tracks.filter(e => { + return e.files.length > 0 + }) + } + }, + methods: { + add () { + 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') } diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index e44a92d4fe09c054ae2ffae43c780371769f270c..5e9965158c7a50ada134f51774f046344810ddd2 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -232,7 +232,7 @@ export default { }, watch: { currentTrack (newValue) { - if (!newValue) { + if (!newValue || !newValue.album.cover) { this.ambiantColors = this.defaultAmbiantColors } }, diff --git a/front/src/components/audio/Track.vue b/front/src/components/audio/Track.vue index a513c468f6792d2013bb25b6b4946a863be667a7..d8dcaff9b393013be60b2cb7e0b82ac0b6329e31 100644 --- a/front/src/components/audio/Track.vue +++ b/front/src/components/audio/Track.vue @@ -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) { diff --git a/front/src/components/auth/Login.vue b/front/src/components/auth/Login.vue index 99b439af8b3e25c821ddb1dbc0c86c6419961d2c..2cf6d5f6db1cd8bc4e4d72d96be5c70d559e9452 100644 --- a/front/src/components/auth/Login.vue +++ b/front/src/components/auth/Login.vue @@ -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' diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index f090581ef7305927e293e3f6ffce3e6033131004..4e8f33289b470526539e71ff40f6de32a2857dcb 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -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' diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue new file mode 100644 index 0000000000000000000000000000000000000000..13b723d201437933d9f6fd46bcaccfb2d316f5ed --- /dev/null +++ b/front/src/components/auth/Signup.vue @@ -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> diff --git a/front/src/components/common/HumanDate.vue b/front/src/components/common/HumanDate.vue new file mode 100644 index 0000000000000000000000000000000000000000..ff6ff5c71efa2df05e1467751235ddb15dd67222 --- /dev/null +++ b/front/src/components/common/HumanDate.vue @@ -0,0 +1,8 @@ +<template> + <time :datetime="date" :title="date | moment">{{ date | ago }}</time> +</template> +<script> +export default { + props: ['date'] +} +</script> diff --git a/front/src/components/discussion/Comment.vue b/front/src/components/discussion/Comment.vue new file mode 100644 index 0000000000000000000000000000000000000000..a3c5176ecb9b908817b72f62f884c6f87d2b9660 --- /dev/null +++ b/front/src/components/discussion/Comment.vue @@ -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> diff --git a/front/src/components/globals.js b/front/src/components/globals.js new file mode 100644 index 0000000000000000000000000000000000000000..40315bc47d8efa9731c7133354181e09bbd6cb59 --- /dev/null +++ b/front/src/components/globals.js @@ -0,0 +1,7 @@ +import Vue from 'vue' + +import HumanDate from '@/components/common/HumanDate' + +Vue.component('human-date', HumanDate) + +export default {} diff --git a/front/src/components/library/Home.vue b/front/src/components/library/Home.vue index cdcbe4b72ee7153d757da33eddb393d1d67a568e..e4e22fc09919f815038795762f408ac0a760aa58 100644 --- a/front/src/components/library/Home.vue +++ b/front/src/components/library/Home.vue @@ -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 { diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue index 5fe192022c8e34416259118f262fb87076e98458..6cd156493f4487683df91fd9314f31fb8807de1d 100644 --- a/front/src/components/library/Library.vue +++ b/front/src/components/library/Library.vue @@ -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> diff --git a/front/src/components/library/import/ImportMixin.vue b/front/src/components/library/import/ImportMixin.vue index 33c6193bd7c0a95afa6e59847dc6056a0ca38145..8b0757dcc9256afa7d09fc4e61eb091668ed7582 100644 --- a/front/src/components/library/import/ImportMixin.vue +++ b/front/src/components/library/import/ImportMixin.vue @@ -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 diff --git a/front/src/components/library/import/Main.vue b/front/src/components/library/import/Main.vue index 66ba8c66144db35f4d4c17dfcada5e4c89fcc2e4..0a1cc6df9480366a7105166045e39ffbf91249de 100644 --- a/front/src/components/library/import/Main.vue +++ b/front/src/components/library/import/Main.vue @@ -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: { diff --git a/front/src/components/requests/Card.vue b/front/src/components/requests/Card.vue new file mode 100644 index 0000000000000000000000000000000000000000..deb9c3fe093a58cc6913805ad4fcbe9bed4da58d --- /dev/null +++ b/front/src/components/requests/Card.vue @@ -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> diff --git a/front/src/components/requests/Form.vue b/front/src/components/requests/Form.vue new file mode 100644 index 0000000000000000000000000000000000000000..68c725ba7adfe903c3ecdf971067b1abb2d5baef --- /dev/null +++ b/front/src/components/requests/Form.vue @@ -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> diff --git a/front/src/components/requests/RequestsList.vue b/front/src/components/requests/RequestsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..cb3e9af00bc40d0301b23dcb446576a567e7f153 --- /dev/null +++ b/front/src/components/requests/RequestsList.vue @@ -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> diff --git a/front/src/filters.js b/front/src/filters.js new file mode 100644 index 0000000000000000000000000000000000000000..22d93149bb402657e56d37d9ef68cf2959d9c767 --- /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 d1ff90c3256846b65848021e1abf0f0b3fb19bc7..2e351310a15e218d1e9438c12d7c8e8ba1b37426 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 971ef05cd82f034b9513716a42f35b02a5291054..827afc21823687dc266a69c601435015757a9045 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 a0071f0961d6536f2133331787d90245a6dc1df4..a4dfcada65028f27a7fd4c61741b1f14d43d6dcd 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 fb348042fa107bdef406406bf67aac596da3d4b9..df8d159f40b4fe3287f9f6252df0ecfdb9023830 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 0000000000000000000000000000000000000000..c2b43da44a83eb0a983d981c7891853a634fb31a --- /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('<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') + }) + }) +})