diff --git a/api/config/api_urls.py b/api/config/api_urls.py
index 3cb7ec36d..ab01e623c 100644
--- a/api/config/api_urls.py
+++ b/api/config/api_urls.py
@@ -40,6 +40,12 @@ v1_patterns += [
r"^manage/",
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
),
+ url(
+ r"^moderation/",
+ include(
+ ("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
+ ),
+ ),
url(
r"^federation/",
include(
diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py
new file mode 100644
index 000000000..fe7d6733a
--- /dev/null
+++ b/api/funkwhale_api/common/views.py
@@ -0,0 +1,9 @@
+class SkipFilterForGetObject:
+ def get_object(self, *args, **kwargs):
+ setattr(self.request, "_skip_filters", True)
+ return super().get_object(*args, **kwargs)
+
+ def filter_queryset(self, queryset):
+ if getattr(self.request, "_skip_filters", False):
+ return queryset
+ return super().filter_queryset(queryset)
diff --git a/api/funkwhale_api/favorites/filters.py b/api/funkwhale_api/favorites/filters.py
index a355593d9..cf8048b8d 100644
--- a/api/funkwhale_api/favorites/filters.py
+++ b/api/funkwhale_api/favorites/filters.py
@@ -1,11 +1,10 @@
-from django_filters import rest_framework as filters
-
from funkwhale_api.common import fields
+from funkwhale_api.moderation import filters as moderation_filters
from . import models
-class TrackFavoriteFilter(filters.FilterSet):
+class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"]
)
@@ -13,3 +12,6 @@ class TrackFavoriteFilter(filters.FilterSet):
class Meta:
model = models.TrackFavorite
fields = ["user", "q"]
+ hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
+ "TRACK_FAVORITE"
+ ]
diff --git a/api/funkwhale_api/history/filters.py b/api/funkwhale_api/history/filters.py
new file mode 100644
index 000000000..30bc78f6a
--- /dev/null
+++ b/api/funkwhale_api/history/filters.py
@@ -0,0 +1,12 @@
+from funkwhale_api.moderation import filters as moderation_filters
+
+from . import models
+
+
+class ListeningFilter(moderation_filters.HiddenContentFilterSet):
+ class Meta:
+ model = models.Listening
+ hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
+ "LISTENING"
+ ]
+ fields = ["hidden"]
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index 56c30af36..b03c85a8e 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -7,7 +7,7 @@ from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils
-from . import models, serializers
+from . import filters, models, serializers
class ListeningViewSet(
@@ -25,6 +25,7 @@ class ListeningViewSet(
IsAuthenticatedOrReadOnly,
]
owner_checks = ["write"]
+ filterset_class = filters.ListeningFilter
def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]:
diff --git a/api/funkwhale_api/moderation/admin.py b/api/funkwhale_api/moderation/admin.py
index 5e421255e..9f8340030 100644
--- a/api/funkwhale_api/moderation/admin.py
+++ b/api/funkwhale_api/moderation/admin.py
@@ -28,3 +28,10 @@ class InstancePolicyAdmin(admin.ModelAdmin):
"summary",
]
list_select_related = True
+
+
+@admin.register(models.UserFilter)
+class UserFilterAdmin(admin.ModelAdmin):
+ list_display = ["uuid", "user", "target_artist", "creation_date"]
+ search_fields = ["target_artist__name", "user__username", "user__email"]
+ list_select_related = True
diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py
index aba5256c9..8829caa2b 100644
--- a/api/funkwhale_api/moderation/factories.py
+++ b/api/funkwhale_api/moderation/factories.py
@@ -2,6 +2,8 @@ import factory
from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.federation import factories as federation_factories
+from funkwhale_api.music import factories as music_factories
+from funkwhale_api.users import factories as users_factories
@registry.register
@@ -21,3 +23,17 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
for_actor = factory.Trait(
target_actor=factory.SubFactory(federation_factories.ActorFactory)
)
+
+
+@registry.register
+class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
+ user = factory.SubFactory(users_factories.UserFactory)
+ target_artist = None
+
+ class Meta:
+ model = "moderation.UserFilter"
+
+ class Params:
+ for_artist = factory.Trait(
+ target_artist=factory.SubFactory(music_factories.ArtistFactory)
+ )
diff --git a/api/funkwhale_api/moderation/filters.py b/api/funkwhale_api/moderation/filters.py
new file mode 100644
index 000000000..ddf183045
--- /dev/null
+++ b/api/funkwhale_api/moderation/filters.py
@@ -0,0 +1,69 @@
+from django.db.models import Q
+
+from django_filters import rest_framework as filters
+
+
+USER_FILTER_CONFIG = {
+ "ARTIST": {"target_artist": ["pk"]},
+ "ALBUM": {"target_artist": ["artist__pk"]},
+ "TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]},
+ "LISTENING": {"target_artist": ["track__album__artist__pk", "track__artist__pk"]},
+ "TRACK_FAVORITE": {
+ "target_artist": ["track__album__artist__pk", "track__artist__pk"]
+ },
+}
+
+
+def get_filtered_content_query(config, user):
+ final_query = None
+ for filter_field, model_fields in config.items():
+ query = None
+ ids = user.content_filters.values_list(filter_field, flat=True)
+ for model_field in model_fields:
+ q = Q(**{"{}__in".format(model_field): ids})
+ if query:
+ query |= q
+ else:
+ query = q
+
+ final_query = query
+ return final_query
+
+
+class HiddenContentFilterSet(filters.FilterSet):
+ """
+ A filterset that include a "hidden" param:
+ - hidden=true : list user hidden/filtered objects
+ - hidden=false : list all objects user hidden/filtered objects
+ - not specified: hidden=false
+
+ Usage:
+
+ class MyFilterSet(HiddenContentFilterSet):
+ class Meta:
+ hidden_content_fields_mapping = {'target_artist': ['pk']}
+
+ Will map UserContentFilter.artist values to the pk field of the filtered model.
+
+ """
+
+ hidden = filters.BooleanFilter(field_name="_", method="filter_hidden_content")
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.data = self.data.copy()
+ self.data.setdefault("hidden", False)
+
+ def filter_hidden_content(self, queryset, name, value):
+ user = self.request.user
+ if not user.is_authenticated:
+ # no filter to apply
+ return queryset
+
+ config = self.__class__.Meta.hidden_content_fields_mapping
+ final_query = get_filtered_content_query(config, user)
+
+ if value is True:
+ return queryset.filter(final_query)
+ else:
+ return queryset.exclude(final_query)
diff --git a/api/funkwhale_api/moderation/migrations/0002_auto_20190213_0927.py b/api/funkwhale_api/moderation/migrations/0002_auto_20190213_0927.py
new file mode 100644
index 000000000..2832a34ef
--- /dev/null
+++ b/api/funkwhale_api/moderation/migrations/0002_auto_20190213_0927.py
@@ -0,0 +1,57 @@
+# Generated by Django 2.1.5 on 2019-02-13 09:27
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("music", "0037_auto_20190103_1757"),
+ ("moderation", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserFilter",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
+ (
+ "creation_date",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "target_artist",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="user_filters",
+ to="music.Artist",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="content_filters",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.AlterUniqueTogether(
+ name="userfilter", unique_together={("user", "target_artist")}
+ ),
+ ]
diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py
index c184bbda8..7ade5d05a 100644
--- a/api/funkwhale_api/moderation/models.py
+++ b/api/funkwhale_api/moderation/models.py
@@ -73,3 +73,22 @@ class InstancePolicy(models.Model):
return {"type": "actor", "obj": self.target_actor}
if self.target_domain_id:
return {"type": "domain", "obj": self.target_domain}
+
+
+class UserFilter(models.Model):
+ uuid = models.UUIDField(default=uuid.uuid4, unique=True)
+ creation_date = models.DateTimeField(default=timezone.now)
+ target_artist = models.ForeignKey(
+ "music.Artist", on_delete=models.CASCADE, related_name="user_filters"
+ )
+ user = models.ForeignKey(
+ "users.User", on_delete=models.CASCADE, related_name="content_filters"
+ )
+
+ class Meta:
+ unique_together = ("user", "target_artist")
+
+ @property
+ def target(self):
+ if self.target_artist:
+ return {"type": "artist", "obj": self.target_artist}
diff --git a/api/funkwhale_api/moderation/serializers.py b/api/funkwhale_api/moderation/serializers.py
new file mode 100644
index 000000000..20c342421
--- /dev/null
+++ b/api/funkwhale_api/moderation/serializers.py
@@ -0,0 +1,45 @@
+from rest_framework import serializers
+
+from funkwhale_api.music import models as music_models
+from . import models
+
+
+class FilteredArtistSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = music_models.Artist
+ fields = ["id", "name"]
+
+
+class TargetSerializer(serializers.Serializer):
+ type = serializers.ChoiceField(choices=["artist"])
+ id = serializers.CharField()
+
+ def to_representation(self, value):
+ if value["type"] == "artist":
+ data = FilteredArtistSerializer(value["obj"]).data
+ data.update({"type": "artist"})
+ return data
+
+ def to_internal_value(self, value):
+ if value["type"] == "artist":
+ field = serializers.PrimaryKeyRelatedField(
+ queryset=music_models.Artist.objects.all()
+ )
+ value["obj"] = field.to_internal_value(value["id"])
+ return value
+
+
+class UserFilterSerializer(serializers.ModelSerializer):
+ target = TargetSerializer()
+
+ class Meta:
+ model = models.UserFilter
+ fields = ["uuid", "target", "creation_date"]
+ read_only_fields = ["uuid", "creation_date"]
+
+ def validate(self, data):
+ target = data.pop("target")
+ if target["type"] == "artist":
+ data["target_artist"] = target["obj"]
+
+ return data
diff --git a/api/funkwhale_api/moderation/urls.py b/api/funkwhale_api/moderation/urls.py
new file mode 100644
index 000000000..05d2e7a92
--- /dev/null
+++ b/api/funkwhale_api/moderation/urls.py
@@ -0,0 +1,8 @@
+from rest_framework import routers
+
+from . import views
+
+router = routers.SimpleRouter()
+router.register(r"content-filters", views.UserFilterViewSet, "content-filters")
+
+urlpatterns = router.urls
diff --git a/api/funkwhale_api/moderation/views.py b/api/funkwhale_api/moderation/views.py
new file mode 100644
index 000000000..feeeccf01
--- /dev/null
+++ b/api/funkwhale_api/moderation/views.py
@@ -0,0 +1,42 @@
+from django.db import IntegrityError
+
+from rest_framework import mixins
+from rest_framework import permissions
+from rest_framework import response
+from rest_framework import status
+from rest_framework import viewsets
+
+from . import models
+from . import serializers
+
+
+class UserFilterViewSet(
+ mixins.ListModelMixin,
+ mixins.CreateModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.DestroyModelMixin,
+ viewsets.GenericViewSet,
+):
+ lookup_field = "uuid"
+ queryset = (
+ models.UserFilter.objects.all()
+ .order_by("-creation_date")
+ .select_related("target_artist")
+ )
+ serializer_class = serializers.UserFilterSerializer
+ permission_classes = [permissions.IsAuthenticated]
+ ordering_fields = ("creation_date",)
+
+ def create(self, request, *args, **kwargs):
+ try:
+ return super().create(request, *args, **kwargs)
+ except IntegrityError:
+ content = {"detail": "A content filter already exists for this object"}
+ return response.Response(content, status=status.HTTP_400_BAD_REQUEST)
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ return qs.filter(user=self.request.user)
+
+ def perform_create(self, serializer):
+ serializer.save(user=self.request.user)
diff --git a/api/funkwhale_api/music/filters.py b/api/funkwhale_api/music/filters.py
index 009f0088d..3134ae19c 100644
--- a/api/funkwhale_api/music/filters.py
+++ b/api/funkwhale_api/music/filters.py
@@ -2,12 +2,13 @@ from django_filters import rest_framework as filters
from funkwhale_api.common import fields
from funkwhale_api.common import search
+from funkwhale_api.moderation import filters as moderation_filters
from . import models
from . import utils
-class ArtistFilter(filters.FilterSet):
+class ArtistFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(search_fields=["name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
@@ -17,13 +18,14 @@ class ArtistFilter(filters.FilterSet):
"name": ["exact", "iexact", "startswith", "icontains"],
"playable": "exact",
}
+ hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value)
-class TrackFilter(filters.FilterSet):
+class TrackFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
@@ -36,6 +38,7 @@ class TrackFilter(filters.FilterSet):
"album": ["exact"],
"license": ["exact"],
}
+ hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
@@ -85,13 +88,14 @@ class UploadFilter(filters.FilterSet):
return queryset.playable_by(actor, value)
-class AlbumFilter(filters.FilterSet):
+class AlbumFilter(moderation_filters.HiddenContentFilterSet):
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter(search_fields=["title", "artist__name"])
class Meta:
model = models.Album
fields = ["playable", "q", "artist"]
+ hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py
index 5de07ca94..d07fd27ec 100644
--- a/api/funkwhale_api/music/views.py
+++ b/api/funkwhale_api/music/views.py
@@ -18,6 +18,7 @@ from taggit.models import Tag
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
+from funkwhale_api.common import views as common_views
from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes
@@ -58,7 +59,7 @@ class TagViewSetMixin(object):
return queryset
-class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
+class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = models.Artist.objects.all()
serializer_class = serializers.ArtistWithAlbumsSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
@@ -82,7 +83,7 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
)
-class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
+class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = (
models.Album.objects.all().order_by("artist", "release_date").select_related()
)
@@ -166,7 +167,9 @@ class LibraryViewSet(
return Response(serializer.data)
-class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
+class TrackViewSet(
+ common_views.SkipFilterForGetObject, TagViewSetMixin, viewsets.ReadOnlyModelViewSet
+):
"""
A simple ViewSet for viewing and editing accounts.
"""
diff --git a/api/funkwhale_api/playlists/models.py b/api/funkwhale_api/playlists/models.py
index 1d3338801..9112dc49c 100644
--- a/api/funkwhale_api/playlists/models.py
+++ b/api/funkwhale_api/playlists/models.py
@@ -17,7 +17,7 @@ class PlaylistQuerySet(models.QuerySet):
def with_covers(self):
album_prefetch = models.Prefetch(
- "album", queryset=music_models.Album.objects.only("cover")
+ "album", queryset=music_models.Album.objects.only("cover", "artist_id")
)
track_prefetch = models.Prefetch(
"track",
diff --git a/api/funkwhale_api/playlists/serializers.py b/api/funkwhale_api/playlists/serializers.py
index b64996640..87350cd04 100644
--- a/api/funkwhale_api/playlists/serializers.py
+++ b/api/funkwhale_api/playlists/serializers.py
@@ -117,9 +117,21 @@ class PlaylistSerializer(serializers.ModelSerializer):
except AttributeError:
return []
+ try:
+ user = self.context["request"].user
+ except (KeyError, AttributeError):
+ excluded_artists = []
+ user = None
+ if user and user.is_authenticated:
+ excluded_artists = list(
+ user.content_filters.values_list("target_artist", flat=True)
+ )
+
covers = []
max_covers = 5
for plt in plts:
+ if plt.track.album.artist_id in excluded_artists:
+ continue
url = plt.track.album.cover.crop["200x200"].url
if url in covers:
continue
diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py
index 8ca15a026..f19c6b884 100644
--- a/api/funkwhale_api/radios/radios.py
+++ b/api/funkwhale_api/radios/radios.py
@@ -5,6 +5,7 @@ from django.db import connection
from rest_framework import serializers
from taggit.models import Tag
+from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.music.models import Artist, Track
from funkwhale_api.users.models import User
@@ -43,7 +44,14 @@ class SessionRadio(SimpleRadio):
return self.session
def get_queryset(self, **kwargs):
- return Track.objects.all()
+ qs = Track.objects.all()
+ if not self.session:
+ return qs
+ query = moderation_filters.get_filtered_content_query(
+ config=moderation_filters.USER_FILTER_CONFIG["TRACK"],
+ user=self.session.user,
+ )
+ return qs.exclude(query)
def get_queryset_kwargs(self):
return {}
diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py
index f7926d1fd..c0b26bf46 100644
--- a/api/funkwhale_api/subsonic/views.py
+++ b/api/funkwhale_api/subsonic/views.py
@@ -13,6 +13,7 @@ import funkwhale_api
from funkwhale_api.activity import record
from funkwhale_api.common import fields, preferences, utils as common_utils
from funkwhale_api.favorites.models import TrackFavorite
+from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils
from funkwhale_api.music import views as music_views
@@ -152,8 +153,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getArtists",
)
def get_artists(self, request, *args, **kwargs):
- artists = music_models.Artist.objects.all().playable_by(
- utils.get_actor_from_request(request)
+ artists = (
+ music_models.Artist.objects.all()
+ .exclude(
+ moderation_filters.get_filtered_content_query(
+ moderation_filters.USER_FILTER_CONFIG["ARTIST"], request.user
+ )
+ )
+ .playable_by(utils.get_actor_from_request(request))
)
data = serializers.GetArtistsSerializer(artists).data
payload = {"artists": data}
@@ -167,8 +174,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getIndexes",
)
def get_indexes(self, request, *args, **kwargs):
- artists = music_models.Artist.objects.all().playable_by(
- utils.get_actor_from_request(request)
+ artists = (
+ music_models.Artist.objects.all()
+ .exclude(
+ moderation_filters.get_filtered_content_query(
+ moderation_filters.USER_FILTER_CONFIG["ARTIST"], request.user
+ )
+ )
+ .playable_by(utils.get_actor_from_request(request))
)
data = serializers.GetArtistsSerializer(artists).data
payload = {"indexes": data}
@@ -273,7 +286,11 @@ class SubsonicViewSet(viewsets.GenericViewSet):
def get_random_songs(self, request, *args, **kwargs):
data = request.GET or request.POST
actor = utils.get_actor_from_request(request)
- queryset = music_models.Track.objects.all()
+ queryset = music_models.Track.objects.all().exclude(
+ moderation_filters.get_filtered_content_query(
+ moderation_filters.USER_FILTER_CONFIG["TRACK"], request.user
+ )
+ )
queryset = queryset.playable_by(actor)
try:
size = int(data["size"])
@@ -308,8 +325,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getAlbumList2",
)
def get_album_list2(self, request, *args, **kwargs):
- queryset = music_models.Album.objects.with_tracks_count().order_by(
- "artist__name"
+ queryset = (
+ music_models.Album.objects.exclude(
+ moderation_filters.get_filtered_content_query(
+ moderation_filters.USER_FILTER_CONFIG["ALBUM"], request.user
+ )
+ )
+ .with_tracks_count()
+ .order_by("artist__name")
)
data = request.GET or request.POST
filterset = filters.AlbumList2FilterSet(data, queryset=queryset)
diff --git a/api/tests/favorites/test_filters.py b/api/tests/favorites/test_filters.py
new file mode 100644
index 000000000..e5eaca39e
--- /dev/null
+++ b/api/tests/favorites/test_filters.py
@@ -0,0 +1,30 @@
+from funkwhale_api.favorites import filters
+from funkwhale_api.favorites import models
+
+
+def test_track_favorite_filter_track_artist(factories, mocker, queryset_equal_list):
+ factories["favorites.TrackFavorite"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_fav = factories["favorites.TrackFavorite"](track__artist=cf.target_artist)
+ qs = models.TrackFavorite.objects.all()
+ filterset = filters.TrackFavoriteFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_fav]
+
+
+def test_track_favorite_filter_track_album_artist(
+ factories, mocker, queryset_equal_list
+):
+ factories["favorites.TrackFavorite"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_fav = factories["favorites.TrackFavorite"](
+ track__album__artist=cf.target_artist
+ )
+ qs = models.TrackFavorite.objects.all()
+ filterset = filters.TrackFavoriteFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_fav]
diff --git a/api/tests/history/test_filters.py b/api/tests/history/test_filters.py
new file mode 100644
index 000000000..257d46bf0
--- /dev/null
+++ b/api/tests/history/test_filters.py
@@ -0,0 +1,28 @@
+from funkwhale_api.history import filters
+from funkwhale_api.history import models
+
+
+def test_listening_filter_track_artist(factories, mocker, queryset_equal_list):
+ factories["history.Listening"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_listening = factories["history.Listening"](track__artist=cf.target_artist)
+ qs = models.Listening.objects.all()
+ filterset = filters.ListeningFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_listening]
+
+
+def test_listening_filter_track_album_artist(factories, mocker, queryset_equal_list):
+ factories["history.Listening"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_listening = factories["history.Listening"](
+ track__album__artist=cf.target_artist
+ )
+ qs = models.Listening.objects.all()
+ filterset = filters.ListeningFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_listening]
diff --git a/api/tests/moderation/__init__.py b/api/tests/moderation/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/api/tests/moderation/test_filters.py b/api/tests/moderation/test_filters.py
new file mode 100644
index 000000000..cb1dab95a
--- /dev/null
+++ b/api/tests/moderation/test_filters.py
@@ -0,0 +1,68 @@
+from funkwhale_api.moderation import filters
+from funkwhale_api.music import models as music_models
+
+
+def test_hidden_defaults_to_true(factories, queryset_equal_list, mocker):
+ user = factories["users.User"]()
+ artist = factories["music.Artist"]()
+ hidden_artist = factories["music.Artist"]()
+ factories["moderation.UserFilter"](target_artist=hidden_artist, user=user)
+
+ class FS(filters.HiddenContentFilterSet):
+ class Meta:
+ hidden_content_fields_mapping = {"target_artist": ["pk"]}
+
+ filterset = FS(
+ data={},
+ queryset=music_models.Artist.objects.all(),
+ request=mocker.Mock(user=user),
+ )
+ assert filterset.data["hidden"] is False
+ queryset = filterset.filter_hidden_content(
+ music_models.Artist.objects.all(), "", False
+ )
+
+ assert queryset == [artist]
+
+
+def test_hidden_false(factories, queryset_equal_list, mocker):
+ user = factories["users.User"]()
+ factories["music.Artist"]()
+ hidden_artist = factories["music.Artist"]()
+ factories["moderation.UserFilter"](target_artist=hidden_artist, user=user)
+
+ class FS(filters.HiddenContentFilterSet):
+ class Meta:
+ hidden_content_fields_mapping = {"target_artist": ["pk"]}
+
+ filterset = FS(
+ data={},
+ queryset=music_models.Artist.objects.all(),
+ request=mocker.Mock(user=user),
+ )
+
+ queryset = filterset.filter_hidden_content(
+ music_models.Artist.objects.all(), "", True
+ )
+
+ assert queryset == [hidden_artist]
+
+
+def test_hidden_anonymous(factories, queryset_equal_list, mocker, anonymous_user):
+ artist = factories["music.Artist"]()
+
+ class FS(filters.HiddenContentFilterSet):
+ class Meta:
+ hidden_content_fields_mapping = {"target_artist": ["pk"]}
+
+ filterset = FS(
+ data={},
+ queryset=music_models.Artist.objects.all(),
+ request=mocker.Mock(user=anonymous_user),
+ )
+
+ queryset = filterset.filter_hidden_content(
+ music_models.Artist.objects.all(), "", True
+ )
+
+ assert queryset == [artist]
diff --git a/api/tests/moderation/test_serializers.py b/api/tests/moderation/test_serializers.py
new file mode 100644
index 000000000..a38214143
--- /dev/null
+++ b/api/tests/moderation/test_serializers.py
@@ -0,0 +1,30 @@
+from funkwhale_api.moderation import serializers
+
+
+def test_user_filter_serializer_repr(factories):
+ artist = factories["music.Artist"]()
+ content_filter = factories["moderation.UserFilter"](target_artist=artist)
+
+ expected = {
+ "uuid": str(content_filter.uuid),
+ "target": {"type": "artist", "id": artist.pk, "name": artist.name},
+ "creation_date": content_filter.creation_date.isoformat().replace(
+ "+00:00", "Z"
+ ),
+ }
+
+ serializer = serializers.UserFilterSerializer(content_filter)
+
+ assert serializer.data == expected
+
+
+def test_user_filter_serializer_save(factories):
+ artist = factories["music.Artist"]()
+ user = factories["users.User"]()
+ data = {"target": {"type": "artist", "id": artist.pk}}
+
+ serializer = serializers.UserFilterSerializer(data=data)
+ serializer.is_valid(raise_exception=True)
+ content_filter = serializer.save(user=user)
+
+ assert content_filter.target_artist == artist
diff --git a/api/tests/moderation/test_views.py b/api/tests/moderation/test_views.py
new file mode 100644
index 000000000..3d53f4565
--- /dev/null
+++ b/api/tests/moderation/test_views.py
@@ -0,0 +1,24 @@
+from django.urls import reverse
+
+
+def test_restrict_to_own_filters(factories, logged_in_api_client):
+ cf = factories["moderation.UserFilter"](
+ for_artist=True, user=logged_in_api_client.user
+ )
+ factories["moderation.UserFilter"](for_artist=True)
+ url = reverse("api:v1:moderation:content-filters-list")
+ response = logged_in_api_client.get(url)
+ assert response.status_code == 200
+ assert response.data["count"] == 1
+ assert response.data["results"][0]["uuid"] == str(cf.uuid)
+
+
+def test_create_filter(factories, logged_in_api_client):
+ artist = factories["music.Artist"]()
+ url = reverse("api:v1:moderation:content-filters-list")
+ data = {"target": {"type": "artist", "id": artist.pk}}
+ response = logged_in_api_client.post(url, data, format="json")
+
+ cf = logged_in_api_client.user.content_filters.latest("id")
+ assert cf.target_artist == artist
+ assert response.status_code == 201
diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py
new file mode 100644
index 000000000..f9abc4b21
--- /dev/null
+++ b/api/tests/music/test_filters.py
@@ -0,0 +1,54 @@
+from funkwhale_api.music import filters
+from funkwhale_api.music import models
+
+
+def test_album_filter_hidden(factories, mocker, queryset_equal_list):
+ factories["music.Album"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_album = factories["music.Album"](artist=cf.target_artist)
+
+ qs = models.Album.objects.all()
+ filterset = filters.AlbumFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_album]
+
+
+def test_artist_filter_hidden(factories, mocker, queryset_equal_list):
+ factories["music.Artist"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_artist = cf.target_artist
+
+ qs = models.Artist.objects.all()
+ filterset = filters.ArtistFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_artist]
+
+
+def test_artist_filter_track_artist(factories, mocker, queryset_equal_list):
+ factories["music.Track"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_track = factories["music.Track"](artist=cf.target_artist)
+
+ qs = models.Track.objects.all()
+ filterset = filters.TrackFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_track]
+
+
+def test_artist_filter_track_album_artist(factories, mocker, queryset_equal_list):
+ factories["music.Track"]()
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ hidden_track = factories["music.Track"](album__artist=cf.target_artist)
+
+ qs = models.Track.objects.all()
+ filterset = filters.TrackFilter(
+ {"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
+ )
+
+ assert filterset.qs == [hidden_track]
diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py
index cedb6bd7f..640e71211 100644
--- a/api/tests/radios/test_radios.py
+++ b/api/tests/radios/test_radios.py
@@ -254,3 +254,27 @@ def test_similar_radio_track(factories):
factories["history.Listening"](track=expected_next, user=l1.user)
assert radio.pick(filter_playable=False) == expected_next
+
+
+def test_session_radio_get_queryset_ignore_filtered_track_artist(
+ factories, queryset_equal_list
+):
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ factories["music.Track"](artist=cf.target_artist)
+ valid_track = factories["music.Track"]()
+ radio = radios.RandomRadio()
+ radio.start_session(user=cf.user)
+
+ assert radio.get_queryset() == [valid_track]
+
+
+def test_session_radio_get_queryset_ignore_filtered_track_album_artist(
+ factories, queryset_equal_list
+):
+ cf = factories["moderation.UserFilter"](for_artist=True)
+ factories["music.Track"](album__artist=cf.target_artist)
+ valid_track = factories["music.Track"]()
+ radio = radios.RandomRadio()
+ radio.start_session(user=cf.user)
+
+ assert radio.get_queryset() == [valid_track]
diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py
index 0dbbaf39a..e75bfc2a0 100644
--- a/api/tests/subsonic/test_views.py
+++ b/api/tests/subsonic/test_views.py
@@ -7,6 +7,7 @@ from django.utils import timezone
from rest_framework.response import Response
import funkwhale_api
+from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.subsonic import renderers, serializers
@@ -100,20 +101,31 @@ def test_ping(f, db, api_client):
def test_get_artists(
f, db, logged_in_api_client, factories, mocker, queryset_equal_queries
):
+ factories["moderation.UserFilter"](
+ user=logged_in_api_client.user,
+ target_artist=factories["music.Artist"](playable=True),
+ )
url = reverse("api:subsonic-get_artists")
assert url.endswith("getArtists") is True
factories["music.Artist"].create_batch(size=3, playable=True)
playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by")
+ exclude_query = moderation_filters.get_filtered_content_query(
+ moderation_filters.USER_FILTER_CONFIG["ARTIST"], logged_in_api_client.user
+ )
+ assert exclude_query is not None
expected = {
"artists": serializers.GetArtistsSerializer(
- music_models.Artist.objects.all()
+ music_models.Artist.objects.all().exclude(exclude_query)
).data
}
response = logged_in_api_client.get(url, {"f": f})
assert response.status_code == 200
assert response.data == expected
- playable_by.assert_called_once_with(music_models.Artist.objects.all(), None)
+ playable_by.assert_called_once_with(
+ music_models.Artist.objects.all().exclude(exclude_query),
+ logged_in_api_client.user.actor,
+ )
@pytest.mark.parametrize("f", ["json"])
@@ -502,12 +514,20 @@ def test_get_music_folders(f, db, logged_in_api_client, factories):
def test_get_indexes(
f, db, logged_in_api_client, factories, mocker, queryset_equal_queries
):
+ factories["moderation.UserFilter"](
+ user=logged_in_api_client.user,
+ target_artist=factories["music.Artist"](playable=True),
+ )
+ exclude_query = moderation_filters.get_filtered_content_query(
+ moderation_filters.USER_FILTER_CONFIG["ARTIST"], logged_in_api_client.user
+ )
+
url = reverse("api:subsonic-get_indexes")
assert url.endswith("getIndexes") is True
factories["music.Artist"].create_batch(size=3, playable=True)
expected = {
"indexes": serializers.GetArtistsSerializer(
- music_models.Artist.objects.all()
+ music_models.Artist.objects.all().exclude(exclude_query)
).data
}
playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by")
@@ -516,7 +536,10 @@ def test_get_indexes(
assert response.status_code == 200
assert response.data == expected
- playable_by.assert_called_once_with(music_models.Artist.objects.all(), None)
+ playable_by.assert_called_once_with(
+ music_models.Artist.objects.all().exclude(exclude_query),
+ logged_in_api_client.user.actor,
+ )
def test_get_cover_art_album(factories, logged_in_api_client):
diff --git a/changes/changelog.d/701.feature b/changes/changelog.d/701.feature
new file mode 100644
index 000000000..d2a9500d6
--- /dev/null
+++ b/changes/changelog.d/701.feature
@@ -0,0 +1 @@
+Allow artists hiding (#701)
diff --git a/changes/notes.rst b/changes/notes.rst
index 96ac3d765..19aa5a077 100644
--- a/changes/notes.rst
+++ b/changes/notes.rst
@@ -5,3 +5,18 @@ Next release notes
Those release notes refer to the current development branch and are reset
after each release.
+
+Artist hiding in the interface
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+It's now possible for users to hide artists they don't want to see.
+
+Content linked to hidden artists will not show up in the interface anymore. Especially:
+
+- Hidden artists tracks are removed from the current queue
+- Starting a playlist will skip tracks from hidden artists
+- Recently favorited, recently listened and recently added widgets on the homepage won't include content from hidden artists
+- Radio suggestions will exclude tracks from hidden artists
+- Hidden artists won't appear in Subsonic apps
+
+Results linked to hidden artists will continue to show up in search results and their profile page remains accessible.
diff --git a/front/src/App.vue b/front/src/App.vue
index 02383e6cf..e06156c18 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -44,6 +44,7 @@
@show:shortcuts-modal="showShortcutsModal = !showShortcutsModal"
>
-
@@ -41,7 +41,7 @@
@@ -62,8 +62,9 @@
@@ -107,6 +108,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+