diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index d77d91f17284c753c9087de78b840213765da43e..91b11e8bd174a1262d0cc70fa6dc4da077152d6e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -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
diff --git a/CHANGELOG b/CHANGELOG
index 9f3551d4926a999cb563f1b4ab2545e9d4e48b55..c854bf0e7d3c72746cc859c6d267fe4f5982a73a 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,8 @@ Changelog
 0.3.3 (Unreleased)
 ------------------
 
+- Users can now create their own dynamic radios (#51)
+
 
 0.3.2
 ------------------
diff --git a/api/config/settings/test.py b/api/config/settings/test.py
index db7b21415c6583a17472a9b84a61d9793e6cc0fa..a0b6b2503a1762295c960cae50c06c65c50a4d76 100644
--- a/api/config/settings/test.py
+++ b/api/config/settings/test.py
@@ -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
 # ------------------------------------------------------------------------------
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/migrations/0004_auto_20180107_1813.py b/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py
new file mode 100644
index 0000000000000000000000000000000000000000..fc768b303365e3146054b7f05749d5dd0fc9d259
--- /dev/null
+++ b/api/funkwhale_api/radios/migrations/0004_auto_20180107_1813.py
@@ -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'),
+        ),
+    ]
diff --git a/api/funkwhale_api/radios/models.py b/api/funkwhale_api/radios/models.py
index 984b34a1f7d86230c6e9186c123246286f0c6eac..d9c12534c03f779d497a1a9e7ae43e12cc3bb32f 100644
--- a/api/funkwhale_api/radios/models.py
+++ b/api/funkwhale_api/radios/models.py
@@ -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)
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/test.yml b/api/test.yml
index bd3a98e457de5cf7610caf1ac5cef4dfd520715f..c59ce45bbbbaf2357a2b50782bc004f51b584cce 100644
--- a/api/test.yml
+++ b/api/test.yml
@@ -1,8 +1,15 @@
-test:
-  dockerfile: docker/Dockerfile.test
-  build: .
-  command: pytest
-  volumes:
-    - .:/app
-  environment:
-    - "DATABASE_URL=sqlite://"
+version: '2'
+services:
+  test:
+    build:
+      dockerfile: docker/Dockerfile.test
+      context: .
+    command: pytest
+    depends_on:
+      - postgres
+    volumes:
+      - .:/app
+    environment:
+      - "DATABASE_URL=postgresql://postgres@postgres/postgres"
+  postgres:
+    image: postgres
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']()
diff --git a/front/src/components/library/Library.vue b/front/src/components/library/Library.vue
index f5303d88c8e14d8a959d541903e23e13ce100528..c27313dc36d2a9d08c0cbba2c0e704c7527c5063 100644
--- a/front/src/components/library/Library.vue
+++ b/front/src/components/library/Library.vue
@@ -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>
diff --git a/front/src/components/library/Radios.vue b/front/src/components/library/Radios.vue
new file mode 100644
index 0000000000000000000000000000000000000000..409b6b6741137f6fef494cd42a11b33648498658
--- /dev/null
+++ b/front/src/components/library/Radios.vue
@@ -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>
diff --git a/front/src/components/library/import/FileUpload.vue b/front/src/components/library/import/FileUpload.vue
index 4681d79322727f9341682f5b8586bc0685001676..93ca75c3961d445f924bf920df7ef5e1da330ab3 100644
--- a/front/src/components/library/import/FileUpload.vue
+++ b/front/src/components/library/import/FileUpload.vue
@@ -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 () {
diff --git a/front/src/components/library/radios/Builder.vue b/front/src/components/library/radios/Builder.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f58d5003fd5b7ac21adac9b77142fa96206e0284
--- /dev/null
+++ b/front/src/components/library/radios/Builder.vue
@@ -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>
diff --git a/front/src/components/library/radios/Filter.vue b/front/src/components/library/radios/Filter.vue
new file mode 100644
index 0000000000000000000000000000000000000000..dd170d8b3104da08e4fbd3b93091182fe4a3a2a4
--- /dev/null
+++ b/front/src/components/library/radios/Filter.vue
@@ -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>
diff --git a/front/src/components/radios/Button.vue b/front/src/components/radios/Button.vue
index 4bf4279890d05f39ac06a350164b9c2101747068..819aa8651f3509827b0460c54c9cec9ded18d23b 100644
--- a/front/src/components/radios/Button.vue
+++ b/front/src/components/radios/Button.vue
@@ -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})
       }
     }
   },
diff --git a/front/src/components/radios/Card.vue b/front/src/components/radios/Card.vue
index dc8a24ff3c2e31d901ee11fb27eceb976ca7e819..d2c14c37c78dfbc23b858c93a5eddd28b3519fb9 100644
--- a/front/src/components/radios/Card.vue
+++ b/front/src/components/radios/Card.vue
@@ -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
     }
   }
 }
diff --git a/front/src/router/index.js b/front/src/router/index.js
index f4efc723f4abc2fb9dfdca2e062d433c09d4e91e..971ef05cd82f034b9513716a42f35b02a5291054 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -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 },
diff --git a/front/src/store/radios.js b/front/src/store/radios.js
index a9c429876a4ff974635de9f73062dd2597237209..600b24b31e7fb77eafdda8a0992bdf58879f3a54 100644
--- a/front/src/store/radios.js
+++ b/front/src/store/radios.js
@@ -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) => {