Merge branch 'release/0.5'

This commit is contained in:
Eliot Berriot 2018-02-24 15:37:46 +01:00
commit ce482314e7
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
73 changed files with 1686 additions and 115 deletions

2
.gitignore vendored
View File

@ -72,7 +72,7 @@ api/music
api/media
api/staticfiles
api/static
api/.pytest_cache
# Front
front/node_modules/

View File

@ -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)
----------------

View File

@ -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

View File

@ -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'),
]

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -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('.')])

View File

@ -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:

View File

@ -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

View File

@ -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()
)

View File

@ -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),
),
]

View File

@ -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),
]

View File

@ -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),
),
]

View File

@ -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),
]

View File

@ -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'),
),
]

View File

@ -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'])

View File

@ -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',)

View File

@ -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'

View File

@ -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])

View File

View File

@ -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

View File

@ -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'

View File

@ -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'],
}

View File

@ -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)),
],
),
]

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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']

View File

@ -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?'
)

View File

@ -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()

View File

@ -5,6 +5,7 @@ libjpeg-dev
zlib1g-dev
libpq-dev
postgresql-client
libav-tools
libmagic-dev
ffmpeg
python3-dev
curl

View File

@ -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

View File

@ -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']()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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/;

View File

@ -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/;
}
}

View File

@ -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",

View File

@ -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>

View File

@ -0,0 +1,10 @@
export default {
formats: [
// 'audio/ogg',
'audio/mpeg'
],
formatsMap: {
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3'
}
}

View File

@ -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>

View File

@ -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>

View File

@ -49,4 +49,8 @@ export default {
</script>
<style scoped>
.ui.menu {
border: none;
box-shadow: none;
}
</style>

View File

@ -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: {

View File

@ -1,6 +1,6 @@
<template>
<div :class="['ui', {'tiny': discrete}, 'buttons']">
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, 'button']">
<button title="Add to current queue" @click="add" :class="['ui', {'mini': discrete}, {disabled: playableTracks.length === 0}, 'button']">
<i class="ui play icon"></i>
<template v-if="!discrete"><slot>Play</slot></template>
</button>
@ -36,20 +36,25 @@ export default {
jQuery(this.$el).find('.ui.dropdown').dropdown()
}
},
computed: {
playableTracks () {
let tracks
if (this.track) {
tracks = [this.track]
} else {
tracks = this.tracks
}
return tracks.filter(e => {
return e.files.length > 0
})
}
},
methods: {
add () {
if (this.track) {
this.$store.dispatch('queue/append', {track: this.track})
} else {
this.$store.dispatch('queue/appendMany', {tracks: this.tracks})
}
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks})
},
addNext (next) {
if (this.track) {
this.$store.dispatch('queue/append', {track: this.track, index: this.$store.state.queue.currentIndex + 1})
} else {
this.$store.dispatch('queue/appendMany', {tracks: this.tracks, index: this.$store.state.queue.currentIndex + 1})
}
this.$store.dispatch('queue/appendMany', {tracks: this.playableTracks, index: this.$store.state.queue.currentIndex + 1})
if (next) {
this.$store.dispatch('queue/next')
}

View File

@ -232,7 +232,7 @@ export default {
},
watch: {
currentTrack (newValue) {
if (!newValue) {
if (!newValue || !newValue.album.cover) {
this.ambiantColors = this.defaultAmbiantColors
}
},

View File

@ -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) {

View File

@ -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'

View File

@ -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'

View File

@ -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>

View File

@ -0,0 +1,8 @@
<template>
<time :datetime="date" :title="date | moment">{{ date | ago }}</time>
</template>
<script>
export default {
props: ['date']
}
</script>

View File

@ -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>

View File

@ -0,0 +1,7 @@
import Vue from 'vue'
import HumanDate from '@/components/common/HumanDate'
Vue.component('human-date', HumanDate)
export default {}

View File

@ -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 {

View File

@ -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>

View File

@ -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

View File

@ -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: {

View File

@ -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>

View File

@ -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>

View File

@ -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>

44
front/src/filters.js Normal file
View File

@ -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 {}

View File

@ -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)

View File

@ -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 }

View File

@ -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)
})

View File

@ -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))

View File

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