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