Merge branch 'feature/51-radio-builder' into 'develop'
Feature/51 radio builder Closes #51 See merge request funkwhale/funkwhale!39
This commit is contained in:
commit
95a8b08ac4
|
@ -11,11 +11,14 @@ stages:
|
|||
- deploy
|
||||
|
||||
test_api:
|
||||
services:
|
||||
- postgres:9.4
|
||||
stage: test
|
||||
image: funkwhale/funkwhale:base
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
|
||||
DATABASE_URL: "sqlite://"
|
||||
DATABASE_URL: "postgresql://postgres@postgres/postgres"
|
||||
|
||||
before_script:
|
||||
- python3 -m venv --copies virtualenv
|
||||
- source virtualenv/bin/activate
|
||||
|
|
|
@ -5,6 +5,8 @@ Changelog
|
|||
0.3.3 (Unreleased)
|
||||
------------------
|
||||
|
||||
- Users can now create their own dynamic radios (#51)
|
||||
|
||||
|
||||
0.3.2
|
||||
------------------
|
||||
|
|
|
@ -1,11 +1,5 @@
|
|||
from .common import * # noqa
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ':memory:',
|
||||
}
|
||||
}
|
||||
|
||||
# Mail settings
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
@ -262,6 +262,16 @@ class Lyrics(models.Model):
|
|||
extensions=['markdown.extensions.nl2br'])
|
||||
|
||||
|
||||
class TrackQuerySet(models.QuerySet):
|
||||
def for_nested_serialization(self):
|
||||
return (self.select_related()
|
||||
.select_related('album__artist')
|
||||
.prefetch_related(
|
||||
'tags',
|
||||
'files',
|
||||
'artist__albums__tracks__tags'))
|
||||
|
||||
|
||||
class Track(APIModelMixin):
|
||||
title = models.CharField(max_length=255)
|
||||
artist = models.ForeignKey(
|
||||
|
@ -302,6 +312,7 @@ class Track(APIModelMixin):
|
|||
import_hooks = [
|
||||
import_tags
|
||||
]
|
||||
objects = TrackQuerySet.as_manager()
|
||||
tags = TaggableManager()
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -116,13 +116,7 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
|
|||
"""
|
||||
A simple ViewSet for viewing and editing accounts.
|
||||
"""
|
||||
queryset = (models.Track.objects.all()
|
||||
.select_related()
|
||||
.select_related('album__artist')
|
||||
.prefetch_related(
|
||||
'tags',
|
||||
'files',
|
||||
'artist__albums__tracks__tags'))
|
||||
queryset = (models.Track.objects.all().for_nested_serialization())
|
||||
serializer_class = serializers.TrackSerializerNested
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
search_fields = ['title', 'artist__name']
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import factory
|
||||
|
||||
from funkwhale_api.factories import registry
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
|
||||
|
||||
@registry.register
|
||||
class RadioFactory(factory.django.DjangoModelFactory):
|
||||
name = factory.Faker('name')
|
||||
description = factory.Faker('paragraphs')
|
||||
user = factory.SubFactory(UserFactory)
|
||||
config = []
|
||||
|
||||
class Meta:
|
||||
model = 'radios.Radio'
|
||||
|
||||
|
||||
@registry.register
|
||||
class RadioSessionFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
|
||||
class Meta:
|
||||
model = 'radios.RadioSession'
|
||||
|
||||
|
||||
@registry.register(name='radios.CustomRadioSession')
|
||||
class RadioSessionFactory(factory.django.DjangoModelFactory):
|
||||
user = factory.SubFactory(UserFactory)
|
||||
radio_type = 'custom'
|
||||
custom_radio = factory.SubFactory(
|
||||
RadioFactory, user=factory.SelfAttribute('..user'))
|
||||
|
||||
class Meta:
|
||||
model = 'radios.RadioSession'
|
|
@ -0,0 +1,201 @@
|
|||
import collections
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
import persisting_theory
|
||||
|
||||
from funkwhale_api.music import models
|
||||
from funkwhale_api.taskapp.celery import require_instance
|
||||
|
||||
|
||||
class RadioFilterRegistry(persisting_theory.Registry):
|
||||
|
||||
def prepare_data(self, data):
|
||||
return data()
|
||||
|
||||
def prepare_name(self, data, name=None):
|
||||
return data.code
|
||||
|
||||
@property
|
||||
def exposed_filters(self):
|
||||
return [
|
||||
f for f in self.values() if f.expose_in_api
|
||||
]
|
||||
|
||||
|
||||
registry = RadioFilterRegistry()
|
||||
|
||||
|
||||
def run(filters, **kwargs):
|
||||
candidates = kwargs.pop('candidates', models.Track.objects.all())
|
||||
final_query = None
|
||||
final_query = registry['group'].get_query(
|
||||
candidates, filters=filters, **kwargs)
|
||||
|
||||
if final_query:
|
||||
candidates = candidates.filter(final_query)
|
||||
return candidates.order_by('pk')
|
||||
|
||||
|
||||
def validate(filter_config):
|
||||
try:
|
||||
f = registry[filter_config['type']]
|
||||
except KeyError:
|
||||
raise ValidationError(
|
||||
'Invalid type "{}"'.format(filter_config['type']))
|
||||
f.validate(filter_config)
|
||||
return True
|
||||
|
||||
|
||||
def test(filter_config, **kwargs):
|
||||
"""
|
||||
Run validation and also gather the candidates for the given config
|
||||
"""
|
||||
data = {
|
||||
'errors': [],
|
||||
'candidates': {
|
||||
'count': None,
|
||||
'sample': None,
|
||||
}
|
||||
}
|
||||
try:
|
||||
validate(filter_config)
|
||||
except ValidationError as e:
|
||||
data['errors'] = [e.message]
|
||||
return data
|
||||
|
||||
candidates = run([filter_config], **kwargs)
|
||||
data['candidates']['count'] = candidates.count()
|
||||
data['candidates']['sample'] = candidates[:10]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def clean_config(filter_config):
|
||||
f = registry[filter_config['type']]
|
||||
return f.clean_config(filter_config)
|
||||
|
||||
|
||||
class RadioFilter(object):
|
||||
help_text = None
|
||||
label = None
|
||||
fields = []
|
||||
expose_in_api = True
|
||||
|
||||
def get_query(self, candidates, **kwargs):
|
||||
return candidates
|
||||
|
||||
def clean_config(self, filter_config):
|
||||
return filter_config
|
||||
|
||||
def validate(self, config):
|
||||
operator = config.get('operator', 'and')
|
||||
try:
|
||||
assert operator in ['or', 'and']
|
||||
except AssertionError:
|
||||
raise ValidationError(
|
||||
'Invalid operator "{}"'.format(config['operator']))
|
||||
|
||||
|
||||
@registry.register
|
||||
class GroupFilter(RadioFilter):
|
||||
code = 'group'
|
||||
expose_in_api = False
|
||||
def get_query(self, candidates, filters, **kwargs):
|
||||
if not filters:
|
||||
return
|
||||
|
||||
final_query = None
|
||||
for filter_config in filters:
|
||||
f = registry[filter_config['type']]
|
||||
conf = collections.ChainMap(filter_config, kwargs)
|
||||
query = f.get_query(candidates, **conf)
|
||||
if filter_config.get('not', False):
|
||||
query = ~query
|
||||
|
||||
if not final_query:
|
||||
final_query = query
|
||||
else:
|
||||
operator = filter_config.get('operator', 'and')
|
||||
if operator == 'and':
|
||||
final_query &= query
|
||||
elif operator == 'or':
|
||||
final_query |= query
|
||||
else:
|
||||
raise ValueError(
|
||||
'Invalid query operator "{}"'.format(operator))
|
||||
return final_query
|
||||
|
||||
def validate(self, config):
|
||||
super().validate(config)
|
||||
for fc in config['filters']:
|
||||
registry[fc['type']].validate(fc)
|
||||
|
||||
|
||||
@registry.register
|
||||
class ArtistFilter(RadioFilter):
|
||||
code = 'artist'
|
||||
label = 'Artist'
|
||||
help_text = 'Select tracks for a given artist'
|
||||
fields = [
|
||||
{
|
||||
'name': 'ids',
|
||||
'type': 'list',
|
||||
'subtype': 'number',
|
||||
'autocomplete': reverse_lazy('api:v1:artists-search'),
|
||||
'autocomplete_qs': 'query={query}',
|
||||
'autocomplete_fields': {'name': 'name', 'value': 'id'},
|
||||
'label': 'Artist',
|
||||
'placeholder': 'Select artists'
|
||||
}
|
||||
]
|
||||
|
||||
def clean_config(self, filter_config):
|
||||
filter_config = super().clean_config(filter_config)
|
||||
filter_config['ids'] = sorted(filter_config['ids'])
|
||||
names = models.Artist.objects.filter(
|
||||
pk__in=filter_config['ids']
|
||||
).order_by('id').values_list('name', flat=True)
|
||||
filter_config['names'] = list(names)
|
||||
return filter_config
|
||||
|
||||
def get_query(self, candidates, ids, **kwargs):
|
||||
return Q(artist__pk__in=ids)
|
||||
|
||||
def validate(self, config):
|
||||
super().validate(config)
|
||||
try:
|
||||
pks = models.Artist.objects.filter(
|
||||
pk__in=config['ids']).values_list('pk', flat=True)
|
||||
diff = set(config['ids']) - set(pks)
|
||||
assert len(diff) == 0
|
||||
except KeyError:
|
||||
raise ValidationError('You must provide an id')
|
||||
except AssertionError:
|
||||
raise ValidationError(
|
||||
'No artist matching ids "{}"'.format(diff))
|
||||
|
||||
|
||||
@registry.register
|
||||
class TagFilter(RadioFilter):
|
||||
code = 'tag'
|
||||
fields = [
|
||||
{
|
||||
'name': 'names',
|
||||
'type': 'list',
|
||||
'subtype': 'string',
|
||||
'autocomplete': reverse_lazy('api:v1:tags-list'),
|
||||
'autocomplete_qs': '',
|
||||
'autocomplete_fields': {'remoteValues': 'results', 'name': 'name', 'value': 'slug'},
|
||||
'autocomplete_qs': 'query={query}',
|
||||
'label': 'Tags',
|
||||
'placeholder': 'Select tags'
|
||||
}
|
||||
]
|
||||
help_text = 'Select tracks with a given tag'
|
||||
label = 'Tag'
|
||||
|
||||
def get_query(self, candidates, names, **kwargs):
|
||||
return Q(tags__slug__in=names)
|
|
@ -0,0 +1,12 @@
|
|||
import django_filters
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class RadioFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = models.Radio
|
||||
fields = {
|
||||
'name': ['exact', 'iexact', 'startswith', 'icontains']
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 2.0 on 2018-01-07 18:13
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('radios', '0003_auto_20160521_1708'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Radio',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('is_public', models.BooleanField(default=False)),
|
||||
('version', models.PositiveIntegerField(default=0)),
|
||||
('config', django.contrib.postgres.fields.jsonb.JSONField()),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='radios', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='radiosession',
|
||||
name='custom_radio',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='radios.Radio'),
|
||||
),
|
||||
]
|
|
@ -1,11 +1,34 @@
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from funkwhale_api.music.models import Track
|
||||
|
||||
from . import filters
|
||||
|
||||
|
||||
class Radio(models.Model):
|
||||
CONFIG_VERSION = 0
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
related_name='radios',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
is_public = models.BooleanField(default=False)
|
||||
version = models.PositiveIntegerField(default=0)
|
||||
config = JSONField()
|
||||
|
||||
def get_candidates(self):
|
||||
return filters.run(self.config)
|
||||
|
||||
|
||||
class RadioSession(models.Model):
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
|
@ -15,6 +38,12 @@ class RadioSession(models.Model):
|
|||
on_delete=models.CASCADE)
|
||||
session_key = models.CharField(max_length=100, null=True, blank=True)
|
||||
radio_type = models.CharField(max_length=50)
|
||||
custom_radio = models.ForeignKey(
|
||||
Radio,
|
||||
related_name='sessions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
related_object_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
|
@ -51,6 +80,7 @@ class RadioSession(models.Model):
|
|||
from . import radios
|
||||
return registry[self.radio_type](session=self)
|
||||
|
||||
|
||||
class RadioSessionTrack(models.Model):
|
||||
session = models.ForeignKey(
|
||||
RadioSession, related_name='session_tracks', on_delete=models.CASCADE)
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import random
|
||||
from rest_framework import serializers
|
||||
from django.core.exceptions import ValidationError
|
||||
from taggit.models import Tag
|
||||
from funkwhale_api.users.models import User
|
||||
from funkwhale_api.music.models import Track, Artist
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
from .registries import registry
|
||||
|
||||
|
||||
class SimpleRadio(object):
|
||||
|
||||
def clean(self, instance):
|
||||
|
@ -50,7 +54,7 @@ class SessionRadio(SimpleRadio):
|
|||
|
||||
def filter_from_session(self, queryset):
|
||||
already_played = self.session.session_tracks.all().values_list('track', flat=True)
|
||||
queryset = queryset.exclude(pk__in=list(already_played))
|
||||
queryset = queryset.exclude(pk__in=already_played)
|
||||
return queryset
|
||||
|
||||
def pick(self, **kwargs):
|
||||
|
@ -64,6 +68,10 @@ class SessionRadio(SimpleRadio):
|
|||
self.session.add(choice)
|
||||
return picked_choices
|
||||
|
||||
def validate_session(self, data, **context):
|
||||
return data
|
||||
|
||||
|
||||
@registry.register(name='random')
|
||||
class RandomRadio(SessionRadio):
|
||||
def get_queryset(self, **kwargs):
|
||||
|
@ -83,6 +91,37 @@ class FavoritesRadio(SessionRadio):
|
|||
return Track.objects.filter(pk__in=track_ids)
|
||||
|
||||
|
||||
@registry.register(name='custom')
|
||||
class CustomRadio(SessionRadio):
|
||||
|
||||
def get_queryset_kwargs(self):
|
||||
kwargs = super().get_queryset_kwargs()
|
||||
kwargs['user'] = self.session.user
|
||||
kwargs['custom_radio'] = self.session.custom_radio
|
||||
return kwargs
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
return filters.run(kwargs['custom_radio'].config)
|
||||
|
||||
def validate_session(self, data, **context):
|
||||
data = super().validate_session(data, **context)
|
||||
try:
|
||||
user = data['user']
|
||||
except KeyError:
|
||||
user = context['user']
|
||||
try:
|
||||
assert (
|
||||
data['custom_radio'].user == user or
|
||||
data['custom_radio'].is_public)
|
||||
except KeyError:
|
||||
raise serializers.ValidationError(
|
||||
'You must provide a custom radio')
|
||||
except AssertionError:
|
||||
raise serializers.ValidationError(
|
||||
"You don't have access to this radio")
|
||||
return data
|
||||
|
||||
|
||||
class RelatedObjectRadio(SessionRadio):
|
||||
"""Abstract radio related to an object (tag, artist, user...)"""
|
||||
|
||||
|
|
|
@ -1,8 +1,39 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from . import models
|
||||
|
||||
from . import filters
|
||||
from . import models
|
||||
from .radios import registry
|
||||
|
||||
|
||||
class FilterSerializer(serializers.Serializer):
|
||||
type = serializers.CharField(source='code')
|
||||
label = serializers.CharField()
|
||||
help_text = serializers.CharField()
|
||||
fields = serializers.ReadOnlyField()
|
||||
|
||||
|
||||
class RadioSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Radio
|
||||
fields = (
|
||||
'id',
|
||||
'is_public',
|
||||
'name',
|
||||
'creation_date',
|
||||
'user',
|
||||
'config',
|
||||
'description')
|
||||
read_only_fields = ('user', 'creation_date')
|
||||
|
||||
def save(self, **kwargs):
|
||||
kwargs['config'] = [
|
||||
filters.registry[f['type']].clean_config(f)
|
||||
for f in self.validated_data['config']
|
||||
]
|
||||
|
||||
return super().save(**kwargs)
|
||||
|
||||
class RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
@ -21,7 +52,18 @@ class RadioSessionTrackSerializer(serializers.ModelSerializer):
|
|||
class RadioSessionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.RadioSession
|
||||
fields = ('id', 'radio_type', 'related_object_id', 'user', 'creation_date', 'session_key')
|
||||
fields = (
|
||||
'id',
|
||||
'radio_type',
|
||||
'related_object_id',
|
||||
'user',
|
||||
'creation_date',
|
||||
'custom_radio',
|
||||
'session_key')
|
||||
|
||||
def validate(self, data):
|
||||
registry[data['radio_type']]().validate_session(data, **self.context)
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
if self.context.get('user'):
|
||||
|
@ -29,7 +71,6 @@ class RadioSessionSerializer(serializers.ModelSerializer):
|
|||
else:
|
||||
validated_data['session_key'] = self.context['session_key']
|
||||
if validated_data.get('related_object_id'):
|
||||
from . import radios
|
||||
radio = radios.registry[validated_data['radio_type']]()
|
||||
radio = registry[validated_data['radio_type']]()
|
||||
validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id'])
|
||||
return super().create(validated_data)
|
||||
|
|
|
@ -4,6 +4,7 @@ from . import views
|
|||
from rest_framework import routers
|
||||
router = routers.SimpleRouter()
|
||||
router.register(r'sessions', views.RadioSessionViewSet, 'sessions')
|
||||
router.register(r'radios', views.RadioViewSet, 'radios')
|
||||
router.register(r'tracks', views.RadioSessionTrackViewSet, 'tracks')
|
||||
|
||||
|
||||
|
|
|
@ -1,14 +1,72 @@
|
|||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
|
||||
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 rest_framework.decorators import detail_route, list_route
|
||||
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||
|
||||
from . import models
|
||||
from . import filters
|
||||
from . import filtersets
|
||||
from . import serializers
|
||||
|
||||
|
||||
class RadioViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
|
||||
serializer_class = serializers.RadioSerializer
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
filter_class = filtersets.RadioFilter
|
||||
|
||||
def get_queryset(self):
|
||||
query = Q(is_public=True)
|
||||
if self.request.user.is_authenticated:
|
||||
query |= Q(user=self.request.user)
|
||||
return models.Radio.objects.filter(query)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
return serializer.save(user=self.request.user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if serializer.instance.user != self.request.user:
|
||||
raise Http404
|
||||
return serializer.save(user=self.request.user)
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def filters(self, request, *args, **kwargs):
|
||||
serializer = serializers.FilterSerializer(
|
||||
filters.registry.exposed_filters, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@list_route(methods=['post'])
|
||||
def validate(self, request, *args, **kwargs):
|
||||
try:
|
||||
f_list = request.data['filters']
|
||||
except KeyError:
|
||||
return Response(
|
||||
{'error': 'You must provide a filters list'}, status=400)
|
||||
data = {
|
||||
'filters': []
|
||||
}
|
||||
for f in f_list:
|
||||
results = filters.test(f)
|
||||
if results['candidates']['sample']:
|
||||
qs = results['candidates']['sample'].for_nested_serialization()
|
||||
results['candidates']['sample'] = TrackSerializerNested(
|
||||
qs, many=True).data
|
||||
data['filters'].append(results)
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
class RadioSessionViewSet(mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
|
|
|
@ -27,11 +27,12 @@ class CeleryConfig(AppConfig):
|
|||
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS, force=True)
|
||||
|
||||
|
||||
def require_instance(model_or_qs, parameter_name):
|
||||
def require_instance(model_or_qs, parameter_name, id_kwarg_name=None):
|
||||
def decorator(function):
|
||||
@functools.wraps(function)
|
||||
def inner(*args, **kwargs):
|
||||
pk = kwargs.pop('_'.join([parameter_name, 'id']))
|
||||
kw = id_kwarg_name or '_'.join([parameter_name, 'id'])
|
||||
pk = kwargs.pop(kw)
|
||||
try:
|
||||
instance = model_or_qs.get(pk=pk)
|
||||
except AttributeError:
|
||||
|
|
11
api/test.yml
11
api/test.yml
|
@ -1,8 +1,15 @@
|
|||
version: '2'
|
||||
services:
|
||||
test:
|
||||
build:
|
||||
dockerfile: docker/Dockerfile.test
|
||||
build: .
|
||||
context: .
|
||||
command: pytest
|
||||
depends_on:
|
||||
- postgres
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
- "DATABASE_URL=sqlite://"
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
postgres:
|
||||
image: postgres
|
||||
|
|
|
@ -0,0 +1,159 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.music.serializers import TrackSerializerNested
|
||||
from funkwhale_api.radios import filters
|
||||
from funkwhale_api.radios import serializers
|
||||
|
||||
|
||||
def test_can_list_config_options(logged_in_client):
|
||||
url = reverse('api:v1:radios:radios-filters')
|
||||
response = logged_in_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = json.loads(response.content.decode('utf-8'))
|
||||
|
||||
expected = [f for f in filters.registry.values() if f.expose_in_api]
|
||||
assert len(payload) == len(expected)
|
||||
|
||||
|
||||
def test_can_validate_config(logged_in_client, factories):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
factories['music.Track'].create_batch(3, artist=artist1)
|
||||
factories['music.Track'].create_batch(3, artist=artist2)
|
||||
candidates = artist1.tracks.order_by('pk')
|
||||
f = {
|
||||
'filters': [
|
||||
{'type': 'artist', 'ids': [artist1.pk]}
|
||||
]
|
||||
}
|
||||
url = reverse('api:v1:radios:radios-validate')
|
||||
response = logged_in_client.post(
|
||||
url,
|
||||
json.dumps(f),
|
||||
content_type="application/json")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = json.loads(response.content.decode('utf-8'))
|
||||
|
||||
expected = {
|
||||
'count': candidates.count(),
|
||||
'sample': TrackSerializerNested(candidates, many=True).data
|
||||
}
|
||||
assert payload['filters'][0]['candidates'] == expected
|
||||
assert payload['filters'][0]['errors'] == []
|
||||
|
||||
|
||||
def test_can_validate_config_with_wrong_config(logged_in_client, factories):
|
||||
f = {
|
||||
'filters': [
|
||||
{'type': 'artist', 'ids': [999]}
|
||||
]
|
||||
}
|
||||
url = reverse('api:v1:radios:radios-validate')
|
||||
response = logged_in_client.post(
|
||||
url,
|
||||
json.dumps(f),
|
||||
content_type="application/json")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = json.loads(response.content.decode('utf-8'))
|
||||
|
||||
expected = {
|
||||
'count': None,
|
||||
'sample': None
|
||||
}
|
||||
assert payload['filters'][0]['candidates'] == expected
|
||||
assert len(payload['filters'][0]['errors']) == 1
|
||||
|
||||
|
||||
def test_saving_radio_sets_user(logged_in_client, factories):
|
||||
artist = factories['music.Artist']()
|
||||
f = {
|
||||
'name': 'Test',
|
||||
'config': [
|
||||
{'type': 'artist', 'ids': [artist.pk]}
|
||||
]
|
||||
}
|
||||
url = reverse('api:v1:radios:radios-list')
|
||||
response = logged_in_client.post(
|
||||
url,
|
||||
json.dumps(f),
|
||||
content_type="application/json")
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
radio = logged_in_client.user.radios.latest('id')
|
||||
assert radio.name == 'Test'
|
||||
assert radio.user == logged_in_client.user
|
||||
|
||||
|
||||
def test_user_can_detail_his_radio(logged_in_client, factories):
|
||||
radio = factories['radios.Radio'](user=logged_in_client.user)
|
||||
url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk})
|
||||
response = logged_in_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_user_can_detail_public_radio(logged_in_client, factories):
|
||||
radio = factories['radios.Radio'](is_public=True)
|
||||
url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk})
|
||||
response = logged_in_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_user_cannot_detail_someone_else_radio(logged_in_client, factories):
|
||||
radio = factories['radios.Radio'](is_public=False)
|
||||
url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk})
|
||||
response = logged_in_client.get(url)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_user_can_edit_his_radio(logged_in_client, factories):
|
||||
radio = factories['radios.Radio'](user=logged_in_client.user)
|
||||
url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk})
|
||||
response = logged_in_client.put(
|
||||
url,
|
||||
json.dumps({'name': 'new', 'config': []}),
|
||||
content_type="application/json")
|
||||
|
||||
radio.refresh_from_db()
|
||||
assert response.status_code == 200
|
||||
assert radio.name == 'new'
|
||||
|
||||
|
||||
def test_user_cannot_edit_someone_else_radio(logged_in_client, factories):
|
||||
radio = factories['radios.Radio']()
|
||||
url = reverse('api:v1:radios:radios-detail', kwargs={'pk': radio.pk})
|
||||
response = logged_in_client.put(
|
||||
url,
|
||||
json.dumps({'name': 'new', 'config': []}),
|
||||
content_type="application/json")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_clean_config_is_called_on_serializer_save(mocker, factories):
|
||||
user = factories['users.User']()
|
||||
artist = factories['music.Artist']()
|
||||
data= {
|
||||
'name': 'Test',
|
||||
'config': [
|
||||
{'type': 'artist', 'ids': [artist.pk]}
|
||||
]
|
||||
}
|
||||
spied = mocker.spy(filters.registry['artist'], 'clean_config')
|
||||
serializer = serializers.RadioSerializer(data=data)
|
||||
assert serializer.is_valid()
|
||||
instance = serializer.save(user=user)
|
||||
spied.assert_called_once_with(data['config'][0])
|
||||
assert instance.config[0]['names'] == [artist.name]
|
|
@ -0,0 +1,161 @@
|
|||
import pytest
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from funkwhale_api.music.models import Track
|
||||
from funkwhale_api.radios import filters
|
||||
|
||||
|
||||
@filters.registry.register
|
||||
class NoopFilter(filters.RadioFilter):
|
||||
code = 'noop'
|
||||
def get_query(self, candidates, **kwargs):
|
||||
return
|
||||
|
||||
|
||||
def test_most_simple_radio_does_not_filter_anything(factories):
|
||||
tracks = factories['music.Track'].create_batch(3)
|
||||
radio = factories['radios.Radio'](config=[{'type': 'noop'}])
|
||||
|
||||
assert radio.version == 0
|
||||
assert radio.get_candidates().count() == 3
|
||||
|
||||
|
||||
|
||||
def test_filter_can_use_custom_queryset(factories):
|
||||
tracks = factories['music.Track'].create_batch(3)
|
||||
candidates = Track.objects.filter(pk=tracks[0].pk)
|
||||
|
||||
qs = filters.run([{'type': 'noop'}], candidates=candidates)
|
||||
assert qs.count() == 1
|
||||
assert qs.first() == tracks[0]
|
||||
|
||||
|
||||
def test_filter_on_tag(factories):
|
||||
tracks = factories['music.Track'].create_batch(3, tags=['metal'])
|
||||
factories['music.Track'].create_batch(3, tags=['pop'])
|
||||
expected = tracks
|
||||
f = [
|
||||
{'type': 'tag', 'names': ['metal']}
|
||||
]
|
||||
|
||||
candidates = filters.run(f)
|
||||
assert list(candidates.order_by('pk')) == expected
|
||||
|
||||
|
||||
def test_filter_on_artist(factories):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
factories['music.Track'].create_batch(3, artist=artist1)
|
||||
factories['music.Track'].create_batch(3, artist=artist2)
|
||||
expected = list(artist1.tracks.order_by('pk'))
|
||||
f = [
|
||||
{'type': 'artist', 'ids': [artist1.pk]}
|
||||
]
|
||||
|
||||
candidates = filters.run(f)
|
||||
assert list(candidates.order_by('pk')) == expected
|
||||
|
||||
|
||||
def test_can_combine_with_or(factories):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
artist3 = factories['music.Artist']()
|
||||
factories['music.Track'].create_batch(3, artist=artist1)
|
||||
factories['music.Track'].create_batch(3, artist=artist2)
|
||||
factories['music.Track'].create_batch(3, artist=artist3)
|
||||
expected = Track.objects.exclude(artist=artist3).order_by('pk')
|
||||
f = [
|
||||
{'type': 'artist', 'ids': [artist1.pk]},
|
||||
{'type': 'artist', 'ids': [artist2.pk], 'operator': 'or'},
|
||||
]
|
||||
|
||||
candidates = filters.run(f)
|
||||
assert list(candidates.order_by('pk')) == list(expected)
|
||||
|
||||
|
||||
def test_can_combine_with_and(factories):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
metal_tracks = factories['music.Track'].create_batch(
|
||||
2, artist=artist1, tags=['metal'])
|
||||
factories['music.Track'].create_batch(2, artist=artist1, tags=['pop'])
|
||||
factories['music.Track'].create_batch(3, artist=artist2)
|
||||
expected = metal_tracks
|
||||
f = [
|
||||
{'type': 'artist', 'ids': [artist1.pk]},
|
||||
{'type': 'tag', 'names': ['metal'], 'operator': 'and'},
|
||||
]
|
||||
|
||||
candidates = filters.run(f)
|
||||
assert list(candidates.order_by('pk')) == list(expected)
|
||||
|
||||
|
||||
def test_can_negate(factories):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
factories['music.Track'].create_batch(3, artist=artist1)
|
||||
factories['music.Track'].create_batch(3, artist=artist2)
|
||||
expected = artist2.tracks.order_by('pk')
|
||||
f = [
|
||||
{'type': 'artist', 'ids': [artist1.pk], 'not': True},
|
||||
]
|
||||
|
||||
candidates = filters.run(f)
|
||||
assert list(candidates.order_by('pk')) == list(expected)
|
||||
|
||||
|
||||
def test_can_group(factories):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
factories['music.Track'].create_batch(2, artist=artist1)
|
||||
t1 = factories['music.Track'].create_batch(
|
||||
2, artist=artist1, tags=['metal'])
|
||||
factories['music.Track'].create_batch(2, artist=artist2)
|
||||
t2 = factories['music.Track'].create_batch(
|
||||
2, artist=artist2, tags=['metal'])
|
||||
factories['music.Track'].create_batch(2, tags=['metal'])
|
||||
expected = t1 + t2
|
||||
f = [
|
||||
{'type': 'tag', 'names': ['metal']},
|
||||
{'type': 'group', 'operator': 'and', 'filters': [
|
||||
{'type': 'artist', 'ids': [artist1.pk], 'operator': 'or'},
|
||||
{'type': 'artist', 'ids': [artist2.pk], 'operator': 'or'},
|
||||
]}
|
||||
]
|
||||
|
||||
candidates = filters.run(f)
|
||||
assert list(candidates.order_by('pk')) == list(expected)
|
||||
|
||||
|
||||
def test_artist_filter_clean_config(factories):
|
||||
artist1 = factories['music.Artist']()
|
||||
artist2 = factories['music.Artist']()
|
||||
|
||||
config = filters.clean_config(
|
||||
{'type': 'artist', 'ids': [artist2.pk, artist1.pk]})
|
||||
|
||||
expected = {
|
||||
'type': 'artist',
|
||||
'ids': [artist1.pk, artist2.pk],
|
||||
'names': [artist1.name, artist2.name]
|
||||
}
|
||||
assert filters.clean_config(config) == expected
|
||||
|
||||
|
||||
def test_can_check_artist_filter(factories):
|
||||
artist = factories['music.Artist']()
|
||||
|
||||
assert filters.validate({'type': 'artist', 'ids': [artist.pk]})
|
||||
with pytest.raises(ValidationError):
|
||||
filters.validate({'type': 'artist', 'ids': [artist.pk + 1]})
|
||||
|
||||
|
||||
def test_can_check_operator():
|
||||
assert filters.validate(
|
||||
{'type': 'group', 'operator': 'or', 'filters': []})
|
||||
assert filters.validate(
|
||||
{'type': 'group', 'operator': 'and', 'filters': []})
|
||||
with pytest.raises(ValidationError):
|
||||
assert filters.validate(
|
||||
{'type': 'group', 'operator': 'nope', 'filters': []})
|
|
@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
|
|||
|
||||
from funkwhale_api.radios import radios
|
||||
from funkwhale_api.radios import models
|
||||
from funkwhale_api.radios import serializers
|
||||
from funkwhale_api.favorites.models import TrackFavorite
|
||||
|
||||
|
||||
|
@ -50,9 +51,9 @@ def test_can_pick_by_weight():
|
|||
|
||||
|
||||
def test_can_get_choices_for_favorites_radio(factories):
|
||||
tracks = factories['music.Track'].create_batch(100)
|
||||
tracks = factories['music.Track'].create_batch(10)
|
||||
user = factories['users.User']()
|
||||
for i in range(20):
|
||||
for i in range(5):
|
||||
TrackFavorite.add(track=random.choice(tracks), user=user)
|
||||
|
||||
radio = radios.FavoritesRadio()
|
||||
|
@ -63,11 +64,54 @@ def test_can_get_choices_for_favorites_radio(factories):
|
|||
for favorite in user.track_favorites.all():
|
||||
assert favorite.track in choices
|
||||
|
||||
for i in range(20):
|
||||
for i in range(5):
|
||||
pick = radio.pick(user=user)
|
||||
assert pick in choices
|
||||
|
||||
|
||||
def test_can_get_choices_for_custom_radio(factories):
|
||||
artist = factories['music.Artist']()
|
||||
tracks = factories['music.Track'].create_batch(5, artist=artist)
|
||||
wrong_tracks = factories['music.Track'].create_batch(5)
|
||||
session = factories['radios.CustomRadioSession'](
|
||||
custom_radio__config=[{'type': 'artist', 'ids': [artist.pk]}]
|
||||
)
|
||||
choices = session.radio.get_choices()
|
||||
|
||||
expected = [t.pk for t in tracks]
|
||||
assert list(choices.values_list('id', flat=True)) == expected
|
||||
|
||||
|
||||
def test_cannot_start_custom_radio_if_not_owner_or_not_public(factories):
|
||||
user = factories['users.User']()
|
||||
artist = factories['music.Artist']()
|
||||
radio = factories['radios.Radio'](
|
||||
config=[{'type': 'artist', 'ids': [artist.pk]}]
|
||||
)
|
||||
serializer = serializers.RadioSessionSerializer(
|
||||
data={
|
||||
'radio_type': 'custom', 'custom_radio': radio.pk, 'user': user.pk}
|
||||
)
|
||||
message = "You don't have access to this radio"
|
||||
assert not serializer.is_valid()
|
||||
assert message in serializer.errors['non_field_errors']
|
||||
|
||||
|
||||
def test_can_start_custom_radio_from_api(logged_in_client, factories):
|
||||
artist = factories['music.Artist']()
|
||||
radio = factories['radios.Radio'](
|
||||
config=[{'type': 'artist', 'ids': [artist.pk]}],
|
||||
user=logged_in_client.user
|
||||
)
|
||||
url = reverse('api:v1:radios:sessions-list')
|
||||
response = logged_in_client.post(
|
||||
url, {'radio_type': 'custom', 'custom_radio': radio.pk})
|
||||
assert response.status_code == 201
|
||||
session = radio.sessions.latest('id')
|
||||
assert session.radio_type == 'custom'
|
||||
assert session.user == logged_in_client.user
|
||||
|
||||
|
||||
def test_can_use_radio_session_to_filter_choices(factories):
|
||||
tracks = factories['music.Track'].create_batch(30)
|
||||
user = factories['users.User']()
|
|
@ -3,6 +3,7 @@
|
|||
<div class="ui secondary pointing menu">
|
||||
<router-link class="ui item" to="/library" exact>Browse</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>
|
||||
<div class="ui secondary right menu">
|
||||
<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>
|
||||
|
|
|
@ -0,0 +1,164 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">Browsing radios</h2>
|
||||
<router-link class="ui green basic button" to="/library/radios/build" exact>Create your own radio</router-link>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div :class="['ui', {'loading': isLoading}, 'form']">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label>Search</label>
|
||||
<input type="text" v-model="query" placeholder="Enter a radio name..."/>
|
||||
</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="radio in result.results"
|
||||
:key="radio.id"
|
||||
class="column">
|
||||
<radio-card class="fluid" type="custom" :custom-radio="radio"></radio-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 _ from 'lodash'
|
||||
import $ from 'jquery'
|
||||
|
||||
import config from '@/config'
|
||||
import logger from '@/logging'
|
||||
|
||||
import OrderingMixin from '@/components/mixins/Ordering'
|
||||
import PaginationMixin from '@/components/mixins/Pagination'
|
||||
import RadioCard from '@/components/radios/Card'
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
const FETCH_URL = config.API_URL + 'radios/radios/'
|
||||
|
||||
export default {
|
||||
mixins: [OrderingMixin, PaginationMixin],
|
||||
props: {
|
||||
defaultQuery: {type: String, required: false, default: ''}
|
||||
},
|
||||
components: {
|
||||
RadioCard,
|
||||
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'],
|
||||
['name', 'Name']
|
||||
]
|
||||
}
|
||||
},
|
||||
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,
|
||||
name__icontains: this.query,
|
||||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
logger.default.debug('Fetching radios')
|
||||
this.$http.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>
|
|
@ -93,18 +93,15 @@ export default {
|
|||
inputFile (newFile, oldFile) {
|
||||
if (newFile && !oldFile) {
|
||||
// add
|
||||
console.log('add', newFile)
|
||||
if (!this.batch) {
|
||||
this.createBatch()
|
||||
}
|
||||
}
|
||||
if (newFile && oldFile) {
|
||||
// update
|
||||
console.log('update', newFile)
|
||||
}
|
||||
if (!newFile && oldFile) {
|
||||
// remove
|
||||
console.log('remove', oldFile)
|
||||
}
|
||||
},
|
||||
createBatch () {
|
||||
|
|
|
@ -0,0 +1,221 @@
|
|||
<template>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div>
|
||||
<div>
|
||||
<h2 class="ui header">Builder</h2>
|
||||
<p>
|
||||
You can use this interface to build your own custom radio, which
|
||||
will play tracks according to your criteria
|
||||
</p>
|
||||
<div class="ui form">
|
||||
<div class="inline fields">
|
||||
<div class="field">
|
||||
<label for="name">Radio name</label>
|
||||
<input id="name" type="text" v-model="radioName" placeholder="My awesome radio" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<input id="public" type="checkbox" v-model="isPublic" />
|
||||
<label for="public">Display publicly</label>
|
||||
</div>
|
||||
<button :disabled="!canSave" @click="save" class="ui green button">Save</button>
|
||||
<radio-button v-if="id" type="custom" :custom-radio-id="id"></radio-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui form">
|
||||
<p>Add filters to customize your radio</p>
|
||||
<div class="inline field">
|
||||
<select class="ui dropdown" v-model="currentFilterType">
|
||||
<option value="">Select a filter</option>
|
||||
<option v-for="f in availableFilters" :value="f.type">{{ f.label }}</option>
|
||||
</select>
|
||||
<button :disabled="!currentFilterType" @click="add" class="ui button">Add filter</button>
|
||||
</div>
|
||||
<p v-if="currentFilter">
|
||||
{{ currentFilter.help_text }}
|
||||
</p>
|
||||
</div>
|
||||
<table class="ui table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="two wide">Filter name</th>
|
||||
<th class="one wide">Exclude</th>
|
||||
<th class="six wide">Config</th>
|
||||
<th class="five wide">Candidates</th>
|
||||
<th class="two wide">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<builder-filter
|
||||
v-for="(f, index) in filters"
|
||||
:key="(f, index, f.hash)"
|
||||
:index="index"
|
||||
@update-config="updateConfig"
|
||||
@delete="deleteFilter"
|
||||
:config="f.config"
|
||||
:filter="f.filter">
|
||||
</builder-filter>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-if="checkResult">
|
||||
<h3 class="ui header">
|
||||
{{ checkResult.candidates.count }} tracks matching combined filters
|
||||
</h3>
|
||||
<track-table v-if="checkResult.candidates.sample" :tracks="checkResult.candidates.sample"></track-table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import config from '@/config'
|
||||
import $ from 'jquery'
|
||||
import _ from 'lodash'
|
||||
import BuilderFilter from './Filter'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import RadioButton from '@/components/radios/Button'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
id: {required: false}
|
||||
},
|
||||
components: {
|
||||
BuilderFilter,
|
||||
TrackTable,
|
||||
RadioButton
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
availableFilters: [],
|
||||
currentFilterType: null,
|
||||
filters: [],
|
||||
checkResult: null,
|
||||
radioName: '',
|
||||
isPublic: true
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
let self = this
|
||||
this.fetchFilters().then(() => {
|
||||
if (self.id) {
|
||||
self.fetch()
|
||||
}
|
||||
})
|
||||
},
|
||||
mounted () {
|
||||
$('.ui.dropdown').dropdown()
|
||||
},
|
||||
methods: {
|
||||
fetchFilters: function () {
|
||||
let self = this
|
||||
let url = config.API_URL + 'radios/radios/filters/'
|
||||
return this.$http.get(url).then((response) => {
|
||||
self.availableFilters = response.data
|
||||
})
|
||||
},
|
||||
add () {
|
||||
this.filters.push({
|
||||
config: {},
|
||||
filter: this.currentFilter,
|
||||
hash: +new Date()
|
||||
})
|
||||
this.fetchCandidates()
|
||||
},
|
||||
updateConfig (index, field, value) {
|
||||
this.filters[index].config[field] = value
|
||||
this.fetchCandidates()
|
||||
},
|
||||
deleteFilter (index) {
|
||||
this.filters.splice(index, 1)
|
||||
this.fetchCandidates()
|
||||
},
|
||||
fetch: function () {
|
||||
let self = this
|
||||
let url = config.API_URL + 'radios/radios/' + this.id + '/'
|
||||
this.$http.get(url).then((response) => {
|
||||
self.filters = response.data.config.map(f => {
|
||||
return {
|
||||
config: f,
|
||||
filter: this.availableFilters.filter(e => { return e.type === f.type })[0],
|
||||
hash: +new Date()
|
||||
}
|
||||
})
|
||||
self.radioName = response.data.name
|
||||
self.isPublic = response.data.is_public
|
||||
})
|
||||
},
|
||||
fetchCandidates: function () {
|
||||
let self = this
|
||||
let url = config.API_URL + 'radios/radios/validate/'
|
||||
let final = this.filters.map(f => {
|
||||
let c = _.clone(f.config)
|
||||
c.type = f.filter.type
|
||||
return c
|
||||
})
|
||||
final = {
|
||||
'filters': [
|
||||
{'type': 'group', filters: final}
|
||||
]
|
||||
}
|
||||
this.$http.post(url, final).then((response) => {
|
||||
self.checkResult = response.data.filters[0]
|
||||
})
|
||||
},
|
||||
save: function () {
|
||||
let self = this
|
||||
let final = this.filters.map(f => {
|
||||
let c = _.clone(f.config)
|
||||
c.type = f.filter.type
|
||||
return c
|
||||
})
|
||||
final = {
|
||||
'name': this.radioName,
|
||||
'is_public': this.isPublic,
|
||||
'config': final
|
||||
}
|
||||
if (this.id) {
|
||||
let url = config.API_URL + 'radios/radios/' + this.id + '/'
|
||||
this.$http.put(url, final).then((response) => {
|
||||
})
|
||||
} else {
|
||||
let url = config.API_URL + 'radios/radios/'
|
||||
this.$http.post(url, final).then((response) => {
|
||||
self.$router.push({
|
||||
name: 'library.radios.edit',
|
||||
params: {
|
||||
id: response.data.id
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canSave: function () {
|
||||
return (
|
||||
this.radioName.length > 0 && this.checkErrors.length === 0
|
||||
)
|
||||
},
|
||||
checkErrors: function () {
|
||||
if (!this.checkResult) {
|
||||
return []
|
||||
}
|
||||
let errors = this.checkResult.errors
|
||||
return errors
|
||||
},
|
||||
currentFilter: function () {
|
||||
let self = this
|
||||
return this.availableFilters.filter(e => {
|
||||
return e.type === self.currentFilterType
|
||||
})[0]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
filters: {
|
||||
handler: function () {
|
||||
this.fetchCandidates()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,150 @@
|
|||
<template>
|
||||
<tr>
|
||||
<td>{{ filter.label }}</td>
|
||||
<td>
|
||||
<div class="ui toggle checkbox">
|
||||
<input name="public" type="checkbox" v-model="exclude" @change="$emit('update-config', index, 'not', exclude)">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
v-for="(f, index) in filter.fields"
|
||||
class="ui field"
|
||||
:key="(f.name, index)"
|
||||
:ref="f.name">
|
||||
<div :class="['ui', 'search', 'selection', 'dropdown', {'autocomplete': f.autocomplete}, {'multiple': f.type === 'list'}]">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="default text">{{ f.placeholder }}</div>
|
||||
<input v-if="f.type === 'list' && config[f.name]" :value="config[f.name].join(',')" type="hidden">
|
||||
<div v-if="config[f.name]" class="ui menu">
|
||||
<div
|
||||
v-if="f.type === 'list'"
|
||||
v-for="(v, index) in config[f.name]"
|
||||
class="ui item"
|
||||
:data-value="v">
|
||||
<template v-if="config.names">
|
||||
{{ config.names[index] }}
|
||||
</template>
|
||||
<template v-else>{{ v }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
@click="showCandidadesModal = !showCandidadesModal"
|
||||
v-if="checkResult"
|
||||
:class="['ui', {'green': checkResult.candidates.count > 10}, 'label']">
|
||||
{{ checkResult.candidates.count }} tracks matching filter
|
||||
</span>
|
||||
<modal v-if="checkResult" :show.sync="showCandidadesModal">
|
||||
<div class="header">
|
||||
Track matching filter
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="description">
|
||||
<track-table v-if="checkResult.candidates.count > 0" :tracks="checkResult.candidates.sample"></track-table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui black deny button">
|
||||
Cancel
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</td>
|
||||
<td>
|
||||
<button @click="$emit('delete', index)" class="ui basic red button">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<script>
|
||||
import config from '@/config'
|
||||
import $ from 'jquery'
|
||||
import _ from 'lodash'
|
||||
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import BuilderFilter from './Filter'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BuilderFilter,
|
||||
TrackTable,
|
||||
Modal
|
||||
},
|
||||
props: {
|
||||
filter: {type: Object},
|
||||
config: {type: Object},
|
||||
index: {type: Number}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
checkResult: null,
|
||||
showCandidadesModal: false,
|
||||
exclude: config.not
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
let self = this
|
||||
this.filter.fields.forEach(f => {
|
||||
let selector = ['.dropdown']
|
||||
let settings = {
|
||||
onChange: function (value, text, $choice) {
|
||||
value = $(this).dropdown('get value').split(',')
|
||||
if (f.type === 'list' && f.subtype === 'number') {
|
||||
value = value.map(e => {
|
||||
return parseInt(e)
|
||||
})
|
||||
}
|
||||
self.value = value
|
||||
self.$emit('update-config', self.index, f.name, value)
|
||||
self.fetchCandidates()
|
||||
}
|
||||
}
|
||||
if (f.type === 'list') {
|
||||
selector.push('.multiple')
|
||||
}
|
||||
if (f.autocomplete) {
|
||||
selector.push('.autocomplete')
|
||||
settings.fields = f.autocomplete_fields
|
||||
settings.minCharacters = 1
|
||||
settings.apiSettings = {
|
||||
url: config.BACKEND_URL + f.autocomplete + '?' + f.autocomplete_qs,
|
||||
beforeXHR: function (xhrObject) {
|
||||
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
|
||||
return xhrObject
|
||||
},
|
||||
onResponse: function (initialResponse) {
|
||||
if (settings.fields.remoteValues) {
|
||||
return initialResponse
|
||||
}
|
||||
return {results: initialResponse}
|
||||
}
|
||||
}
|
||||
}
|
||||
$(self.$el).find(selector.join('')).dropdown(settings)
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
fetchCandidates: function () {
|
||||
let self = this
|
||||
let url = config.API_URL + 'radios/radios/validate/'
|
||||
let final = _.clone(this.config)
|
||||
final.type = this.filter.type
|
||||
final = {'filters': [final]}
|
||||
this.$http.post(url, final).then((response) => {
|
||||
self.checkResult = response.data.filters[0]
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
exclude: function () {
|
||||
this.fetchCandidates()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -11,7 +11,8 @@
|
|||
|
||||
export default {
|
||||
props: {
|
||||
type: {type: String, required: true},
|
||||
customRadioId: {required: false},
|
||||
type: {type: String, required: false},
|
||||
objectId: {type: Number, default: null}
|
||||
},
|
||||
methods: {
|
||||
|
@ -19,7 +20,7 @@ export default {
|
|||
if (this.running) {
|
||||
this.$store.dispatch('radios/stop')
|
||||
} else {
|
||||
this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId})
|
||||
this.$store.dispatch('radios/start', {type: this.type, objectId: this.objectId, customRadioId: this.customRadioId})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">Radio : {{ radio.name }}</div>
|
||||
<div class="header">{{ radio.name }}</div>
|
||||
<div class="description">
|
||||
{{ radio.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<radio-button class="right floated button" :type="type"></radio-button>
|
||||
<router-link
|
||||
class="ui basic yellow button"
|
||||
v-if="$store.state.auth.authenticated && type === 'custom' && customRadio.user === $store.state.auth.profile.id"
|
||||
:to="{name: 'library.radios.edit', params: {id: customRadioId }}">
|
||||
Edit...
|
||||
</router-link>
|
||||
<radio-button class="right floated button" :type="type" :custom-radio-id="customRadioId"></radio-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -17,14 +23,24 @@ import RadioButton from './Button'
|
|||
|
||||
export default {
|
||||
props: {
|
||||
type: {type: String, required: true}
|
||||
type: {type: String, required: true},
|
||||
customRadio: {required: false}
|
||||
},
|
||||
components: {
|
||||
RadioButton
|
||||
},
|
||||
computed: {
|
||||
radio () {
|
||||
if (this.customRadio) {
|
||||
return this.customRadio
|
||||
}
|
||||
return this.$store.getters['radios/types'][this.type]
|
||||
},
|
||||
customRadioId: function () {
|
||||
if (this.customRadio) {
|
||||
return this.customRadio.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import LibraryArtists from '@/components/library/Artists'
|
|||
import LibraryAlbum from '@/components/library/Album'
|
||||
import LibraryTrack from '@/components/library/Track'
|
||||
import LibraryImport from '@/components/library/import/Main'
|
||||
import LibraryRadios from '@/components/library/Radios'
|
||||
import RadioBuilder from '@/components/library/radios/Builder'
|
||||
import BatchList from '@/components/library/import/BatchList'
|
||||
import BatchDetail from '@/components/library/import/BatchDetail'
|
||||
|
||||
|
@ -76,6 +78,19 @@ export default new Router({
|
|||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'radios/',
|
||||
name: 'library.radios.browse',
|
||||
component: LibraryRadios,
|
||||
props: (route) => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{ path: 'radios/build', name: 'library.radios.build', component: RadioBuilder, props: true },
|
||||
{ path: 'radios/build/:id', name: 'library.radios.edit', component: RadioBuilder, props: true },
|
||||
{ path: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
|
||||
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
|
||||
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },
|
||||
|
|
|
@ -38,15 +38,16 @@ export default {
|
|||
}
|
||||
},
|
||||
actions: {
|
||||
start ({commit, dispatch}, {type, objectId}) {
|
||||
start ({commit, dispatch}, {type, objectId, customRadioId}) {
|
||||
let resource = Vue.resource(CREATE_RADIO_URL)
|
||||
var params = {
|
||||
radio_type: type,
|
||||
related_object_id: objectId
|
||||
related_object_id: objectId,
|
||||
custom_radio: customRadioId
|
||||
}
|
||||
resource.save({}, params).then((response) => {
|
||||
logger.default.info('Successfully started radio ', type)
|
||||
commit('current', {type, objectId, session: response.data.id})
|
||||
commit('current', {type, objectId, session: response.data.id, customRadioId})
|
||||
commit('running', true)
|
||||
dispatch('populateQueue')
|
||||
}, (response) => {
|
||||
|
|
Loading…
Reference in New Issue