diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index f919ff0eb3b0b0b6619699b5c65967096bbcec18..f8373ab4d0f286dae34da01dfbffe8967ed9f9a0 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -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:
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index dd295ae79f66a2192f9770a24c0b2fb53c5ea49e..2395454c46429a5b48ccee7fe22e25bf39808318 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -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']
diff --git a/api/funkwhale_api/radios/factories.py b/api/funkwhale_api/radios/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a80323beaba10032d98aa5bf80f23b86fa619b4
--- /dev/null
+++ b/api/funkwhale_api/radios/factories.py
@@ -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'
diff --git a/api/funkwhale_api/radios/filters.py b/api/funkwhale_api/radios/filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..344a4dabff3fb29b1d0fc7d5b8f4bda480c786ae
--- /dev/null
+++ b/api/funkwhale_api/radios/filters.py
@@ -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)
diff --git a/api/funkwhale_api/radios/filtersets.py b/api/funkwhale_api/radios/filtersets.py
new file mode 100644
index 0000000000000000000000000000000000000000..49f471373e55cccdd58d1d574cdd0961e3bb10f7
--- /dev/null
+++ b/api/funkwhale_api/radios/filtersets.py
@@ -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']
+        }
diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py
index 43819b9c4e1079a98e0bc47a1ab50855f6a12b07..585bbbe334f0c3ca297d9ada8883b48bb848354c 100644
--- a/api/funkwhale_api/radios/radios.py
+++ b/api/funkwhale_api/radios/radios.py
@@ -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...)"""
 
diff --git a/api/funkwhale_api/radios/serializers.py b/api/funkwhale_api/radios/serializers.py
index c389e06db57b854ac15647bed03a7c4c4b15c328..520e98652f4238de071ae49f419525f745e97785 100644
--- a/api/funkwhale_api/radios/serializers.py
+++ b/api/funkwhale_api/radios/serializers.py
@@ -1,8 +1,39 @@
 from rest_framework import serializers
 
 from funkwhale_api.music.serializers import TrackSerializerNested
+
+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)
diff --git a/api/funkwhale_api/radios/urls.py b/api/funkwhale_api/radios/urls.py
index 8c31df0937c748639fcf524bd234ec7a966309b0..d84615ca57ceba484ba5b6580804948294b2fc4f 100644
--- a/api/funkwhale_api/radios/urls.py
+++ b/api/funkwhale_api/radios/urls.py
@@ -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')
 
 
diff --git a/api/funkwhale_api/radios/views.py b/api/funkwhale_api/radios/views.py
index 9243d6a90cc5c2ed7a8c420d2f1cc5cea5a9d41d..42652644224446ccade4d43b36c995581bb15783 100644
--- a/api/funkwhale_api/radios/views.py
+++ b/api/funkwhale_api/radios/views.py
@@ -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):
diff --git a/api/funkwhale_api/taskapp/celery.py b/api/funkwhale_api/taskapp/celery.py
index 12e5b24525baf753c56329a3bb12c808ae7f073d..60b09bece8a6bb2e7aa457383602279431b0575e 100644
--- a/api/funkwhale_api/taskapp/celery.py
+++ b/api/funkwhale_api/taskapp/celery.py
@@ -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:
diff --git a/api/tests/radios/test_api.py b/api/tests/radios/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..25c099014e91edea53a00850af44ba967480a7bd
--- /dev/null
+++ b/api/tests/radios/test_api.py
@@ -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]
diff --git a/api/tests/radios/test_filters.py b/api/tests/radios/test_filters.py
new file mode 100644
index 0000000000000000000000000000000000000000..27166b4ab27d9f6e00dad4da69cea75253844e4e
--- /dev/null
+++ b/api/tests/radios/test_filters.py
@@ -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': []})
diff --git a/api/tests/test_radios.py b/api/tests/radios/test_radios.py
similarity index 77%
rename from api/tests/test_radios.py
rename to api/tests/radios/test_radios.py
index d67611123ce0febb2623345d4fe388304b86f7bf..b00bfcd79ce3b917a26698ac4d868dea39521428 100644
--- a/api/tests/test_radios.py
+++ b/api/tests/radios/test_radios.py
@@ -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']()