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:
Eliot Berriot 2018-01-07 21:22:20 +00:00
commit 95a8b08ac4
28 changed files with 1438 additions and 44 deletions

View File

@ -11,11 +11,14 @@ stages:
- deploy - deploy
test_api: test_api:
services:
- postgres:9.4
stage: test stage: test
image: funkwhale/funkwhale:base image: funkwhale/funkwhale:base
variables: variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache" PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
DATABASE_URL: "sqlite://" DATABASE_URL: "postgresql://postgres@postgres/postgres"
before_script: before_script:
- python3 -m venv --copies virtualenv - python3 -m venv --copies virtualenv
- source virtualenv/bin/activate - source virtualenv/bin/activate

View File

@ -5,6 +5,8 @@ Changelog
0.3.3 (Unreleased) 0.3.3 (Unreleased)
------------------ ------------------
- Users can now create their own dynamic radios (#51)
0.3.2 0.3.2
------------------ ------------------

View File

@ -1,11 +1,5 @@
from .common import * # noqa from .common import * # noqa
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test') SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Mail settings # Mail settings
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View File

@ -262,6 +262,16 @@ class Lyrics(models.Model):
extensions=['markdown.extensions.nl2br']) 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): class Track(APIModelMixin):
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
artist = models.ForeignKey( artist = models.ForeignKey(
@ -302,6 +312,7 @@ class Track(APIModelMixin):
import_hooks = [ import_hooks = [
import_tags import_tags
] ]
objects = TrackQuerySet.as_manager()
tags = TaggableManager() tags = TaggableManager()
class Meta: class Meta:

View File

@ -116,13 +116,7 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
""" """
A simple ViewSet for viewing and editing accounts. A simple ViewSet for viewing and editing accounts.
""" """
queryset = (models.Track.objects.all() queryset = (models.Track.objects.all().for_nested_serialization())
.select_related()
.select_related('album__artist')
.prefetch_related(
'tags',
'files',
'artist__albums__tracks__tags'))
serializer_class = serializers.TrackSerializerNested serializer_class = serializers.TrackSerializerNested
permission_classes = [ConditionalAuthentication] permission_classes = [ConditionalAuthentication]
search_fields = ['title', 'artist__name'] search_fields = ['title', 'artist__name']

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,34 @@
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from funkwhale_api.music.models import Track 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): class RadioSession(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
'users.User', 'users.User',
@ -15,6 +38,12 @@ class RadioSession(models.Model):
on_delete=models.CASCADE) on_delete=models.CASCADE)
session_key = models.CharField(max_length=100, null=True, blank=True) session_key = models.CharField(max_length=100, null=True, blank=True)
radio_type = models.CharField(max_length=50) 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) creation_date = models.DateTimeField(default=timezone.now)
related_object_content_type = models.ForeignKey( related_object_content_type = models.ForeignKey(
ContentType, ContentType,
@ -51,6 +80,7 @@ class RadioSession(models.Model):
from . import radios from . import radios
return registry[self.radio_type](session=self) return registry[self.radio_type](session=self)
class RadioSessionTrack(models.Model): class RadioSessionTrack(models.Model):
session = models.ForeignKey( session = models.ForeignKey(
RadioSession, related_name='session_tracks', on_delete=models.CASCADE) RadioSession, related_name='session_tracks', on_delete=models.CASCADE)

View File

@ -1,11 +1,15 @@
import random import random
from rest_framework import serializers
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
from funkwhale_api.music.models import Track, Artist from funkwhale_api.music.models import Track, Artist
from . import filters
from . import models from . import models
from .registries import registry from .registries import registry
class SimpleRadio(object): class SimpleRadio(object):
def clean(self, instance): def clean(self, instance):
@ -50,7 +54,7 @@ class SessionRadio(SimpleRadio):
def filter_from_session(self, queryset): def filter_from_session(self, queryset):
already_played = self.session.session_tracks.all().values_list('track', flat=True) 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 return queryset
def pick(self, **kwargs): def pick(self, **kwargs):
@ -64,6 +68,10 @@ class SessionRadio(SimpleRadio):
self.session.add(choice) self.session.add(choice)
return picked_choices return picked_choices
def validate_session(self, data, **context):
return data
@registry.register(name='random') @registry.register(name='random')
class RandomRadio(SessionRadio): class RandomRadio(SessionRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
@ -83,6 +91,37 @@ class FavoritesRadio(SessionRadio):
return Track.objects.filter(pk__in=track_ids) 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): class RelatedObjectRadio(SessionRadio):
"""Abstract radio related to an object (tag, artist, user...)""" """Abstract radio related to an object (tag, artist, user...)"""

View File

@ -1,8 +1,39 @@
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.music.serializers import TrackSerializerNested 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 RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
class Meta: class Meta:
@ -21,7 +52,18 @@ class RadioSessionTrackSerializer(serializers.ModelSerializer):
class RadioSessionSerializer(serializers.ModelSerializer): class RadioSessionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.RadioSession 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): def create(self, validated_data):
if self.context.get('user'): if self.context.get('user'):
@ -29,7 +71,6 @@ class RadioSessionSerializer(serializers.ModelSerializer):
else: else:
validated_data['session_key'] = self.context['session_key'] validated_data['session_key'] = self.context['session_key']
if validated_data.get('related_object_id'): if validated_data.get('related_object_id'):
from . import radios radio = registry[validated_data['radio_type']]()
radio = radios.registry[validated_data['radio_type']]()
validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id']) validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id'])
return super().create(validated_data) return super().create(validated_data)

View File

@ -4,6 +4,7 @@ from . import views
from rest_framework import routers from rest_framework import routers
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'sessions', views.RadioSessionViewSet, 'sessions') router.register(r'sessions', views.RadioSessionViewSet, 'sessions')
router.register(r'radios', views.RadioViewSet, 'radios')
router.register(r'tracks', views.RadioSessionTrackViewSet, 'tracks') router.register(r'tracks', views.RadioSessionTrackViewSet, 'tracks')

View File

@ -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 generics, mixins, viewsets
from rest_framework import status from rest_framework import status
from rest_framework.response import Response 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.music.serializers import TrackSerializerNested
from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.common.permissions import ConditionalAuthentication
from . import models from . import models
from . import filters
from . import filtersets
from . import serializers 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, class RadioSessionViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):

View File

@ -27,11 +27,12 @@ class CeleryConfig(AppConfig):
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS, force=True) 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): def decorator(function):
@functools.wraps(function) @functools.wraps(function)
def inner(*args, **kwargs): 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: try:
instance = model_or_qs.get(pk=pk) instance = model_or_qs.get(pk=pk)
except AttributeError: except AttributeError:

View File

@ -1,8 +1,15 @@
test: version: '2'
dockerfile: docker/Dockerfile.test services:
build: . test:
command: pytest build:
volumes: dockerfile: docker/Dockerfile.test
- .:/app context: .
environment: command: pytest
- "DATABASE_URL=sqlite://" depends_on:
- postgres
volumes:
- .:/app
environment:
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
postgres:
image: postgres

View File

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

View File

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

View File

@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
from funkwhale_api.radios import radios from funkwhale_api.radios import radios
from funkwhale_api.radios import models from funkwhale_api.radios import models
from funkwhale_api.radios import serializers
from funkwhale_api.favorites.models import TrackFavorite 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): 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']() user = factories['users.User']()
for i in range(20): for i in range(5):
TrackFavorite.add(track=random.choice(tracks), user=user) TrackFavorite.add(track=random.choice(tracks), user=user)
radio = radios.FavoritesRadio() radio = radios.FavoritesRadio()
@ -63,11 +64,54 @@ def test_can_get_choices_for_favorites_radio(factories):
for favorite in user.track_favorites.all(): for favorite in user.track_favorites.all():
assert favorite.track in choices assert favorite.track in choices
for i in range(20): for i in range(5):
pick = radio.pick(user=user) pick = radio.pick(user=user)
assert pick in choices 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): def test_can_use_radio_session_to_filter_choices(factories):
tracks = factories['music.Track'].create_batch(30) tracks = factories['music.Track'].create_batch(30)
user = factories['users.User']() user = factories['users.User']()

View File

@ -3,6 +3,7 @@
<div class="ui secondary pointing menu"> <div class="ui secondary pointing menu">
<router-link class="ui item" to="/library" exact>Browse</router-link> <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/artists" exact>Artists</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 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>

View File

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

View File

@ -93,18 +93,15 @@ export default {
inputFile (newFile, oldFile) { inputFile (newFile, oldFile) {
if (newFile && !oldFile) { if (newFile && !oldFile) {
// add // add
console.log('add', newFile)
if (!this.batch) { if (!this.batch) {
this.createBatch() this.createBatch()
} }
} }
if (newFile && oldFile) { if (newFile && oldFile) {
// update // update
console.log('update', newFile)
} }
if (!newFile && oldFile) { if (!newFile && oldFile) {
// remove // remove
console.log('remove', oldFile)
} }
}, },
createBatch () { createBatch () {

View File

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

View File

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

View File

@ -11,7 +11,8 @@
export default { export default {
props: { props: {
type: {type: String, required: true}, customRadioId: {required: false},
type: {type: String, required: false},
objectId: {type: Number, default: null} objectId: {type: Number, default: null}
}, },
methods: { methods: {
@ -19,7 +20,7 @@ export default {
if (this.running) { if (this.running) {
this.$store.dispatch('radios/stop') this.$store.dispatch('radios/stop')
} else { } 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})
} }
} }
}, },

View File

@ -1,13 +1,19 @@
<template> <template>
<div class="ui card"> <div class="ui card">
<div class="content"> <div class="content">
<div class="header">Radio : {{ radio.name }}</div> <div class="header">{{ radio.name }}</div>
<div class="description"> <div class="description">
{{ radio.description }} {{ radio.description }}
</div> </div>
</div> </div>
<div class="extra content"> <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>
</div> </div>
</template> </template>
@ -17,14 +23,24 @@ import RadioButton from './Button'
export default { export default {
props: { props: {
type: {type: String, required: true} type: {type: String, required: true},
customRadio: {required: false}
}, },
components: { components: {
RadioButton RadioButton
}, },
computed: { computed: {
radio () { radio () {
if (this.customRadio) {
return this.customRadio
}
return this.$store.getters['radios/types'][this.type] return this.$store.getters['radios/types'][this.type]
},
customRadioId: function () {
if (this.customRadio) {
return this.customRadio.id
}
return null
} }
} }
} }

View File

@ -13,6 +13,8 @@ import LibraryArtists from '@/components/library/Artists'
import LibraryAlbum from '@/components/library/Album' import LibraryAlbum from '@/components/library/Album'
import LibraryTrack from '@/components/library/Track' import LibraryTrack from '@/components/library/Track'
import LibraryImport from '@/components/library/import/Main' 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 BatchList from '@/components/library/import/BatchList'
import BatchDetail from '@/components/library/import/BatchDetail' import BatchDetail from '@/components/library/import/BatchDetail'
@ -76,6 +78,19 @@ export default new Router({
defaultPage: route.query.page 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: 'artists/:id', name: 'library.artists.detail', component: LibraryArtist, props: true },
{ path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true }, { path: 'albums/:id', name: 'library.albums.detail', component: LibraryAlbum, props: true },
{ path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true }, { path: 'tracks/:id', name: 'library.tracks.detail', component: LibraryTrack, props: true },

View File

@ -38,15 +38,16 @@ export default {
} }
}, },
actions: { actions: {
start ({commit, dispatch}, {type, objectId}) { start ({commit, dispatch}, {type, objectId, customRadioId}) {
let resource = Vue.resource(CREATE_RADIO_URL) let resource = Vue.resource(CREATE_RADIO_URL)
var params = { var params = {
radio_type: type, radio_type: type,
related_object_id: objectId related_object_id: objectId,
custom_radio: customRadioId
} }
resource.save({}, params).then((response) => { resource.save({}, params).then((response) => {
logger.default.info('Successfully started radio ', type) 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) commit('running', true)
dispatch('populateQueue') dispatch('populateQueue')
}, (response) => { }, (response) => {