diff --git a/api/config/api_urls.py b/api/config/api_urls.py index 3cb7ec36daf2b7b9f564c7c3d33df1dd58e7303a..ab01e623c3e5bd298ee17fe79d5fdecd604a1aee 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 0000000000000000000000000000000000000000..fe7d6733aba97fa481adc85b5ce4c19a1beee9ce --- /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 a355593d91bea643277086fe98d7e81a1653e998..cf8048b8d712d5e8da5dbab80df2093b29fbf794 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 0000000000000000000000000000000000000000..30bc78f6a9d0a10394a1d76bf2e5ea4de0c6e8aa --- /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 56c30af36511e8cf124645dfcd00f835e3a04287..b03c85a8ef1a3834f934a299998ec7230d8631e1 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 5e421255ed344d61335edbb588f7792d07ac21ec..9f8340030e3aa4108ee7f63a87b0c2f8a50e85d9 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 aba5256c9cba399cf2e345101eb0d9958f1b13f4..8829caa2bacf60b444b18984c71b24b5f8b788d0 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 0000000000000000000000000000000000000000..ddf183045869bd01eeb71e2f3cfe80341e57e244 --- /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 0000000000000000000000000000000000000000..2832a34ef669b2d10aec088d6bae4347bb88091b --- /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 c184bbda8117929da26940a1197b36c6cb75f6f4..7ade5d05a1e5a1ff0bfb0e259b5770b100a77489 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 0000000000000000000000000000000000000000..20c34242102d4f302ac29a9781a059c08fef2fb9 --- /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 0000000000000000000000000000000000000000..05d2e7a9223774db0329f66ae295255cb88d9b7a --- /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 0000000000000000000000000000000000000000..feeeccf016686bbe754f492a43cb998756ce76f8 --- /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 009f0088d74999e72c9ac57200f416857849a8a6..3134ae19c987f086d3b4d80ce1ceb4abb903e976 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 5de07ca947edb23ac3a7334c6f94c294e1b64a15..d07fd27ec40f9a7173e563c9c50f132238adb13d 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 1d33388015a80c618628b4923311cab49f15298c..9112dc49cd5f8c255cf50880752d469c24d383d2 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 b64996640259c03a6394c1353bea641afb31dcf5..87350cd04a3bec09e04cf19bd186fefeb7dfbe7d 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 8ca15a026fd523feae3ecf094e9ec1bf66996005..f19c6b884d299fd53c17b472cc8324f7beb69d97 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 f7926d1fdc09250f049045ea8f72ac9542e1a0f8..c0b26bf4634c0cdf4cbd0418f763c71d494543f3 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 0000000000000000000000000000000000000000..e5eaca39ebcf9c265cff93dbed9466c5e93b4ca5 --- /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 0000000000000000000000000000000000000000..257d46bf0c0b311ff57c0b71f90d32b0beea38c1 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/api/tests/moderation/test_filters.py b/api/tests/moderation/test_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..cb1dab95a309cf5a8c001989e1a164ed30d9c2a2 --- /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 0000000000000000000000000000000000000000..a38214143db36f97aa349f712681a3dab2fcbf1b --- /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 0000000000000000000000000000000000000000..3d53f4565a315b84c40fccd7d8b6c226a6883a24 --- /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 0000000000000000000000000000000000000000..f9abc4b2156b6a537471bb83ec583ecfb477faee --- /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 cedb6bd7f856afefe6c9de617a32e70a3b0b83b1..640e712117bfb2ffc641af43daaa0eb5268ad0fd 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 0dbbaf39a2b6018017351dbf51400b3b62ad5d35..e75bfc2a0de554cc9e6940ea12c240469ec27221 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 0000000000000000000000000000000000000000..d2a9500d6b1fd40f6d4ec33dd9f034bc307cf37c --- /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 96ac3d7651f92166072a2fb200c0dd57606851e3..19aa5a077c5e7425bb4b732470c8340f87e8b72c 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 02383e6cfbf41e17e45c5382f57d75e764eadc7f..e06156c182a1f3bbb860d17dab886d6ea2567f0f 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -44,6 +44,7 @@ @show:shortcuts-modal="showShortcutsModal = !showShortcutsModal" ></app-footer> <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> + <filter-modal v-if="$store.state.auth.authenticated"></filter-modal> <shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal> <GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/> </template> @@ -63,6 +64,7 @@ import ServiceMessages from '@/components/ServiceMessages' import moment from 'moment' import locales from './locales' import PlaylistModal from '@/components/playlists/PlaylistModal' +import FilterModal from '@/components/moderation/FilterModal' import ShortcutsModal from '@/components/ShortcutsModal' export default { @@ -70,6 +72,7 @@ export default { components: { Sidebar, AppFooter, + FilterModal, PlaylistModal, ShortcutsModal, GlobalEvents, diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 3a5bf2db8a3ddbee831bbbdf5d88c7fc1d018b3f..865618b12cc8ffd58dec9f50e35b1f15d82e4a3c 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -259,6 +259,29 @@ export default { ? 0 : container.clientHeight / 2 container.scrollTop = container.scrollTop - scrollBack + }, + applyContentFilters () { + let artistIds = this.$store.getters['moderation/artistFilters']().map((f) => { + return f.target.id + }) + + if (artistIds.length === 0) { + return + } + let self = this + let tracks = this.tracks.slice().reverse() + tracks.forEach(async (t, i) => { + // we loop from the end because removing index from the start can lead to removing the wrong tracks + let realIndex = tracks.length - i - 1 + let matchArtist = artistIds.indexOf(t.artist.id) > -1 + if (matchArtist) { + return await self.cleanTrack(realIndex) + } + if (t.album && artistIds.indexOf(t.album.artist.id) > -1) { + return await self.cleanTrack(realIndex) + } + }) + } }, watch: { @@ -274,6 +297,9 @@ export default { if (this.selectedTab !== "queue") { this.scrollToCurrent() } + }, + "$store.state.moderation.lastUpdate": function () { + this.applyContentFilters() } } } diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index a84ee67d5d25cd809cd08c5d34b0dfe0d5b465a2..9e16a556fdf36be448cda10a48a5e63a0d465a18 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,5 +1,5 @@ <template> - <span :title="title" :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]"> + <span :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]"> <button v-if="!dropdownOnly" :title="labels.playNow" @@ -9,8 +9,8 @@ <i :class="[playIconClass, 'icon']"></i> <template v-if="!discrete && !iconOnly"><slot><translate :translate-context="'*/Queue/Button/Label/Short, Verb'">Play</translate></slot></template> </button> - <div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"> - <i :class="dropdownIconClasses.concat(['icon'])"></i> + <div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"> + <i :class="dropdownIconClasses.concat(['icon'])" :title="title" ></i> <div class="menu"> <button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue"> <i class="plus icon"></i><translate :translate-context="'*/Queue/Dropdown/Button/Label/Short'">Add to queue</translate> @@ -24,6 +24,9 @@ <button v-if="track" class="item basic" :disabled="!playable" @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" :title="labels.startRadio"> <i class="feed icon"></i><translate :translate-context="'*/Queue/Dropdown/Button/Label/Short'">Start radio</translate> </button> + <button v-if="filterableArtist" class="item basic" :disabled="!filterableArtist" @click.stop.prevent="filterArtist" :title="labels.hideArtist"> + <i class="eye slash outline icon"></i><translate :translate-context="'*/Queue/Dropdown/Button/Label/Short'">Hide content from this artist</translate> + </button> </div> </div> </span> @@ -45,13 +48,13 @@ export default { discrete: {type: Boolean, default: false}, dropdownOnly: {type: Boolean, default: false}, iconOnly: {type: Boolean, default: false}, - artist: {type: Number, required: false}, - album: {type: Number, required: false}, + artist: {type: Object, required: false}, + album: {type: Object, required: false}, isPlayable: {type: Boolean, required: false, default: null} }, data () { return { - isLoading: false + isLoading: false, } }, mounted () { @@ -91,7 +94,7 @@ export default { if (this.track) { return this.track.uploads && this.track.uploads.length > 0 } else if (this.artist) { - return this.albums.filter((a) => { + return this.artist.albums.filter((a) => { return a.is_playable === true }).length > 0 } else if (this.tracks) { @@ -100,9 +103,24 @@ export default { }).length > 0 } return false + }, + filterableArtist () { + if (this.track) { + return this.track.artist + } + if (this.album) { + return this.album.artist + } + if (this.artist) { + return this.artist + } } }, methods: { + + filterArtist () { + this.$store.dispatch('moderation/hide', {type: 'artist', target: this.filterableArtist}) + }, getTracksPage (page, params, resolve, tracks) { if (page > 10) { // it's 10 * 100 tracks already, let's stop here @@ -113,6 +131,7 @@ export default { let self = this params['page_size'] = 100 params['page'] = page + params['hidden'] = '' tracks = tracks || [] axios.get('tracks/', {params: params}).then((response) => { response.data.results.forEach(t => { @@ -143,15 +162,27 @@ export default { } else if (self.playlist) { let url = 'playlists/' + self.playlist.id + '/' axios.get(url + 'tracks/').then((response) => { - resolve(response.data.results.map(plt => { + let artistIds = self.$store.getters['moderation/artistFilters']().map((f) => { + return f.target.id + }) + let tracks = response.data.results.map(plt => { return plt.track - })) + }) + if (artistIds.length > 0) { + // skip tracks from hidden artists + tracks = tracks.filter((t) => { + let matchArtist = artistIds.indexOf(t.artist.id) > -1 + return !(matchArtist || t.album && artistIds.indexOf(t.album.artist.id) > -1) + }) + } + + resolve(tracks) }) } else if (self.artist) { - let params = {'artist': self.artist, 'ordering': 'album__release_date,position'} + let params = {'artist': self.artist.id, 'ordering': 'album__release_date,position'} self.getTracksPage(1, params, resolve) } else if (self.album) { - let params = {'album': self.album, 'ordering': 'position'} + let params = {'album': self.album.id, 'ordering': 'position'} self.getTracksPage(1, params, resolve) } }) @@ -192,7 +223,7 @@ export default { content: this.$gettextInterpolate(msg, {count: tracks.length}), date: new Date() }) - } + }, } } </script> diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue index 5df3248bf36da2a461ecb89a5f9e1b252282d10f..6d506b9660416d8e5782a49f5b764c0ba4ef97c8 100644 --- a/front/src/components/audio/Player.vue +++ b/front/src/components/audio/Player.vue @@ -38,6 +38,14 @@ v-if="$store.state.auth.authenticated" :class="['inverted']" :track="currentTrack"></track-playlist-icon> + <button + v-if="$store.state.auth.authenticated" + @click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})" + :class="['ui', 'really', 'basic', 'circular', 'inverted', 'icon', 'button']" + :aria-label="labels.addArtistContentFilter" + :title="labels.addArtistContentFilter"> + <i :class="['eye slash outline', 'basic', 'icon']"></i> + </button> </div> </div> </div> @@ -353,13 +361,13 @@ export default { let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track") let unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute") let mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute") - let loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip', + let loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Looping disabled. Click to switch to single-track looping." ) - let loopingSingle = this.$pgettext('Sidebar/Player/Icon.Tooltip', + let loopingSingle = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Looping on a single track. Click to switch to whole queue looping." ) - let loopingWhole = this.$pgettext('Sidebar/Player/Icon.Tooltip', + let loopingWhole = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Looping on whole queue. Click to disable looping." ) let shuffle = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Shuffle your queue") diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 75505d6f922b7130b3fe26d1be20c27ff553b399..1f4021ef5db7c41f43e05aab768d8f27947c1c73 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -45,7 +45,7 @@ </div> </div> <div class="extra content"> - <play-button class="mini basic orange right floated" :tracks="album.tracks"> + <play-button class="mini basic orange right floated" :tracks="album.tracks" :album="album"> <translate :translate-context="'Content/Queue/Card.Button.Label/Short, Verb'">Play all</translate> </play-button> <span> diff --git a/front/src/components/audio/album/Widget.vue b/front/src/components/audio/album/Widget.vue index f7100a9b86c3f33ea5ac3d0bef26f84d3f45f8ec..7d8d82cc766865d9b9057795d6628fca566f8513 100644 --- a/front/src/components/audio/album/Widget.vue +++ b/front/src/components/audio/album/Widget.vue @@ -12,7 +12,7 @@ </div> <div class="card" v-for="album in albums" :key="album.id"> <div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover.original}]" v-lazy:background-image="getImageUrl(album)"> - <play-button class="play-overlay" :icon-only="true" :is-playable="album.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album.id"></play-button> + <play-button class="play-overlay" :icon-only="true" :is-playable="album.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :album="album"></play-button> </div> <div class="content"> <router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}"> @@ -28,7 +28,7 @@ </div> <div class="extra content"> <human-date class="left floated" :date="album.creation_date"></human-date> - <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="album.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :album="album.id"></play-button> + <play-button class="right floated basic icon" :dropdown-only="true" :is-playable="album.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :album="album"></play-button> </div> </div> </div> @@ -101,6 +101,9 @@ export default { watch: { offset () { this.fetchData() + }, + "$store.state.moderation.lastUpdate": function () { + this.fetchData('albums/') } } } diff --git a/front/src/components/audio/artist/Card.vue b/front/src/components/audio/artist/Card.vue index 82af12524c20b24f7f8c80f894dfb46f605676fb..d0223ac2d87d96d6dbbe89df7da229f6a671f013 100644 --- a/front/src/components/audio/artist/Card.vue +++ b/front/src/components/audio/artist/Card.vue @@ -21,7 +21,7 @@ {{ album.tracks_count }} tracks </td> <td> - <play-button class="right floated basic icon" :is-playable="album.is_playable" :discrete="true" :album="album.id"></play-button> + <play-button class="right floated basic icon" :is-playable="album.is_playable" :discrete="true" :album="album"></play-button> </td> </tr> </tbody> @@ -41,7 +41,7 @@ <i class="sound icon"></i> <translate :translate-context="'Content/Artist/Card'" :translate-params="{count: artist.albums.length}" :translate-n="artist.albums.length" translate-plural="%{ count } albums">1 album</translate> </span> - <play-button :is-playable="isPlayable" class="mini basic orange right floated" :artist="artist.id"> + <play-button :is-playable="isPlayable" class="mini basic orange right floated" :artist="artist"> <translate :translate-context="'Content/Queue/Button.Label/Short, Verb'">Play all</translate> </play-button> </div> diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue index f909945f0f97119fcb3ab3a36d8943bfe9513a3b..b8ad3c639c66eb756ca7f3d50148d08aa5e7c5df 100644 --- a/front/src/components/audio/track/Widget.vue +++ b/front/src/components/audio/track/Widget.vue @@ -103,6 +103,9 @@ export default { watch: { offset () { this.fetchData() + }, + "$store.state.moderation.lastUpdate": function () { + this.fetchData(this.url) } } } diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 7081e0298df9536d4c9ef457acedc285d40d816d..73515a1ba3a901c049ea0cf7d3205fc17d3f281a 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -29,8 +29,8 @@ </button> </form> </section> - <div class="ui hidden divider"></div> <section class="ui small text container"> + <div class="ui divider"></div> <h2 class="ui header"> <translate :translate-context="'Content/Settings/Title'">Avatar</translate> </h2> @@ -62,8 +62,9 @@ </div> </div> </section> - <div class="ui hidden divider"></div> + <section class="ui small text container"> + <div class="ui divider"></div> <h2 class="ui header"> <translate :translate-context="'Content/Settings/Title/Verb'">Change my password</translate> </h2> @@ -107,6 +108,53 @@ <div class="ui hidden divider" /> <subsonic-token-form /> </section> + + <section class="ui small text container" id="content-filters"> + <div class="ui divider"></div> + <h2 class="ui header"> + <i class="eye slash outline icon"></i> + <div class="content"> + <translate>Content filters</translate> + </div> + </h2> + <p><translate>Content filters help you hide content you don't want to see on the service.</translate></p> + + <button + @click="$store.dispatch('moderation/fetchContentFilters')" + class="ui basic icon button"> + <i class="refresh icon"></i> + <translate :translate-context="'Content/*/Button.Label'">Refresh</translate> + </button> + <h3 class="ui header"> + <translate>Hidden artists</translate> + </h3> + <table class="ui compact very basic fixed single line unstackable table"> + <thead> + <tr> + <th><translate :translate-context="'Content/*/Table.Label'">Name</translate></th> + <th><translate :translate-context="'Content/*/Table.Label'">Creation date</translate></th> + <th></th> + </tr> + </thead> + <tbody> + <tr v-for="filter in $store.getters['moderation/artistFilters']()" :key='filter.uuid'> + <td> + <router-link :to="{name: 'library.artists.detail', params: {id: filter.target.id }}"> + {{ filter.target.name }} + </router-link> + </td> + <td> + <human-date :date="filter.creation_date"></human-date> + </td> + <td> + <button @click="$store.dispatch('moderation/deleteContentFilter', filter.uuid)" class="ui basic tiny button"> + <translate :translate-context="'Content/*/Button.Label'">Delete</translate> + </button> + </td> + </tr> + </tbody> + </table> + </section> </div> </main> </template> diff --git a/front/src/components/library/Albums.vue b/front/src/components/library/Albums.vue index 5934bbe3bf5e10a11f0f29a5f5726813e36d86a0..f35d5236053078fe669ac6e94f847d3835c2f530 100644 --- a/front/src/components/library/Albums.vue +++ b/front/src/components/library/Albums.vue @@ -176,6 +176,9 @@ export default { query() { this.updateQueryString() this.fetchData() + }, + "$store.state.moderation.lastUpdate": function () { + this.fetchData() } } } diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index c5f6acfb71a601a07f2cfab86be5fee474f5f35d..a5c0a0f45e575aecedfa080a599d6aa338d9969a 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -23,7 +23,7 @@ </h2> <div class="ui hidden divider"></div> <radio-button type="artist" :object-id="artist.id"></radio-button> - <play-button :is-playable="isPlayable" class="orange" :artist="artist.id"> + <play-button :is-playable="isPlayable" class="orange" :artist="artist"> <translate :translate-context="'Content/Artist/Button.Label/Verb'">Play all albums</translate> </play-button> @@ -37,6 +37,20 @@ </a> </div> </section> + <div class="ui small text container" v-if="contentFilter"> + <div class="ui hidden divider"></div> + <div class="ui message"> + <p> + <translate>You are currently hiding content related to this artist.</translate> + </p> + <router-link class="right floated" :to="{name: 'settings'}"> + <translate :translate-context="'Content/Moderation/Link'">Review my filters</translate> + </router-link> + <button @click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)" class="ui basic tiny button"> + <translate :translate-context="'Content/Moderation/Button.Label'">Remove filter</translate> + </button> + </div> + </div> <section v-if="isLoadingAlbums" class="ui vertical stripe segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </section> @@ -105,7 +119,7 @@ export default { var self = this this.isLoading = true logger.default.debug('Fetching artist "' + this.id + '"') - axios.get("tracks/", { params: { artist: this.id } }).then(response => { + axios.get("tracks/", { params: { artist: this.id, hidden: '' } }).then(response => { self.tracks = response.data.results self.totalTracks = response.data.count }) @@ -115,7 +129,7 @@ export default { self.isLoadingAlbums = true axios .get("albums/", { - params: { artist: self.id, ordering: "-release_date" } + params: { artist: self.id, ordering: "-release_date", hidden: '' } }) .then(response => { self.totalAlbums = response.data.count @@ -180,6 +194,12 @@ export default { this.$store.getters["instance/absoluteUrl"](this.cover.original) + ")" ) + }, + contentFilter () { + let self = this + return this.$store.getters['moderation/artistFilters']().filter((e) => { + return e.target.id === this.artist.id + })[0] } }, watch: { diff --git a/front/src/components/library/Artists.vue b/front/src/components/library/Artists.vue index 4fc30977e9edad811b90f92d7648505547ee8d37..64e5ab6c944e45cde389b081a2fa3102470a3ed0 100644 --- a/front/src/components/library/Artists.vue +++ b/front/src/components/library/Artists.vue @@ -173,6 +173,9 @@ export default { query() { this.updateQueryString() this.fetchData() + }, + "$store.state.moderation.lastUpdate": function () { + this.fetchData() } } } diff --git a/front/src/components/moderation/FilterModal.vue b/front/src/components/moderation/FilterModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..39a876546686935b31688db2e9a5083afe1c8c50 --- /dev/null +++ b/front/src/components/moderation/FilterModal.vue @@ -0,0 +1,109 @@ +<template> + <modal @update:show="update" :show="$store.state.moderation.showFilterModal"> + <div class="header"> + <translate + v-if="type === 'artist'" + key="1" + :translate-context="'Popup/Moderation/Title/Verb'" + :translate-params="{name: target.name}">Do you want to hide content from artist "%{ name }"?</translate> + </div> + <div class="scrolling content"> + <div class="description"> + + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header"><translate :translate-context="'Popup/Moderation/Error message'">Error while creating filter</translate></div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <template v-if="type === 'artist'"> + <p> + <translate :translate-context="'Popup/Moderation/Paragraph'"> + You will not see tracks, albums and user activity linked to this artist anymore: + </translate> + </p> + <ul> + <li><translate :translate-context="'Popup/Moderation/List item'">In other users favorites and listening history</translate></li> + <li><translate :translate-context="'Popup/Moderation/List item'">In "Recently added" widget</translate></li> + <li><translate :translate-context="'Popup/Moderation/List item'">In artists and album listings</translate></li> + <li><translate :translate-context="'Popup/Moderation/List item'">In radio suggestions</translate></li> + </ul> + <p> + <translate :translate-context="'Popup/Moderation/Paragraph'"> + You can manage and update your filters anytime from your account settings. + </translate> + </p> + </template> + </div> + </div> + <div class="actions"> + <div class="ui cancel button"><translate :translate-context="'Popup/*/Button.Label'">Cancel</translate></div> + <div :class="['ui', 'green', {loading: isLoading}, 'button']" @click="hide"><translate :translate-context="'Popup/*/Button.Label'">Hide content</translate></div> + </div> + </modal> +</template> + +<script> +import _ from '@/lodash' +import axios from 'axios' +import {mapState} from 'vuex' + +import logger from '@/logging' +import Modal from '@/components/semantic/Modal' + +export default { + components: { + Modal, + }, + data () { + return { + formKey: String(new Date()), + errors: [], + isLoading: false + } + }, + computed: { + ...mapState({ + type: state => state.moderation.filterModalTarget.type, + target: state => state.moderation.filterModalTarget.target, + }) + }, + methods: { + update (v) { + this.$store.commit('moderation/showFilterModal', v) + this.errors = [] + }, + hide () { + let self = this + self.isLoading = true + let payload = { + target: { + type: this.type, + id: this.target.id, + } + } + return axios.post('moderation/content-filters/', payload).then(response => { + logger.default.info('Successfully added track to playlist') + self.update(false) + self.$store.commit('moderation/lastUpdate', new Date()) + self.isLoading = false + let msg = this.$pgettext('*/Moderation/Message', 'Content filter successfully added') + self.$store.commit('moderation/contentFilter', response.data) + self.$store.commit('ui/addMessage', { + content: msg, + date: new Date() + }) + }, error => { + console.log('error', error) + logger.default.error(`Error while hiding ${self.type} ${self.target.id}`) + self.errors = error.backendErrors + self.isLoading = false + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/components/playlists/Widget.vue b/front/src/components/playlists/Widget.vue index 7329c502ec27f0f1f55cf41abea6636c9cf53ea7..b018f9731e97b307df3ded23a5d1660c6eba9ea9 100644 --- a/front/src/components/playlists/Widget.vue +++ b/front/src/components/playlists/Widget.vue @@ -6,7 +6,6 @@ <button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button> <button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button> <button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button> - <div v-if="isLoading" class="ui inverted active dimmer"> <div class="ui loader"></div> </div> @@ -71,6 +70,9 @@ export default { watch: { offset () { this.fetchData() + }, + "$store.state.moderation.lastUpdate": function () { + this.fetchData(this.url) } } } diff --git a/front/src/lodash.js b/front/src/lodash.js index 91e1a0eac30bdf5104a18913578bafcb35298fa6..afc53da2686668bfe4f28fed3f5a657f45786885 100644 --- a/front/src/lodash.js +++ b/front/src/lodash.js @@ -10,4 +10,5 @@ export default { sortBy: require('lodash/sortBy'), throttle: require('lodash/throttle'), uniq: require('lodash/uniq'), + remove: require('lodash/remove'), } diff --git a/front/src/store/auth.js b/front/src/store/auth.js index 90cd27e9e094b62534ec1ed94c325e4a4ae8ae7e..8893bcb495d1645584ad04da06d72091b6de2e6a 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -125,6 +125,7 @@ export default { }) dispatch('ui/fetchUnreadNotifications', null, { root: true }) dispatch('favorites/fetch', null, { root: true }) + dispatch('moderation/fetchContentFilters', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true }) }, (response) => { logger.default.info('Error while fetching user profile') diff --git a/front/src/store/index.js b/front/src/store/index.js index 2454dd20a1497333d5a7b3416859952fdd323d32..e46aea86de226a678eaa27c0666249a44a526941 100644 --- a/front/src/store/index.js +++ b/front/src/store/index.js @@ -5,6 +5,7 @@ import createPersistedState from 'vuex-persistedstate' import favorites from './favorites' import auth from './auth' import instance from './instance' +import moderation from './moderation' import queue from './queue' import radios from './radios' import player from './player' @@ -19,6 +20,7 @@ export default new Vuex.Store({ auth, favorites, instance, + moderation, queue, radios, playlists, diff --git a/front/src/store/instance.js b/front/src/store/instance.js index d0bff7dcb217007ff662838f5f96c2fb26b009aa..6af36e8090e3ba276c7fabaf870ebd251f086a15 100644 --- a/front/src/store/instance.js +++ b/front/src/store/instance.js @@ -104,6 +104,7 @@ export default { let modules = [ 'auth', 'favorites', + 'moderation', 'player', 'playlists', 'queue', diff --git a/front/src/store/moderation.js b/front/src/store/moderation.js new file mode 100644 index 0000000000000000000000000000000000000000..153f3cd5959d307d0edbf7760a4ff34d6a8eced3 --- /dev/null +++ b/front/src/store/moderation.js @@ -0,0 +1,93 @@ +import axios from 'axios' +import logger from '@/logging' +import _ from '@/lodash' + +export default { + namespaced: true, + state: { + filters: [], + showFilterModal: false, + lastUpdate: new Date(), + filterModalTarget: { + type: null, + target: null, + } + }, + mutations: { + filterModalTarget (state, value) { + state.filterModalTarget = value + }, + empty (state) { + state.filters = [] + }, + lastUpdate (state, value) { + state.lastUpdate = value + }, + contentFilter (state, value) { + state.filters.push(value) + }, + showFilterModal (state, value) { + state.showFilterModal = value + if (!value) { + state.filterModalTarget = { + type: null, + target: null, + } + } + }, + reset (state) { + state.filters = [] + state.filterModalTarget = null + state.showFilterModal = false + }, + deleteContentFilter (state, uuid) { + state.filters = state.filters.filter((e) => { + return e.uuid != uuid + }) + } + }, + getters: { + artistFilters: (state) => () => { + let f = state.filters.filter((f) => { + return f.target.type === 'artist' + }) + let p = _.sortBy(f, [(e) => { return e.creation_date }]) + p.reverse() + return p + }, + }, + actions: { + hide ({commit}, payload) { + commit('filterModalTarget', payload) + commit('showFilterModal', true) + }, + fetchContentFilters ({dispatch, state, commit, rootState}, url) { + let params = {} + let promise + if (url) { + promise = axios.get(url) + } else { + commit('empty') + params = { + page_size: 100, + ordering: '-creation_date' + } + promise = axios.get('moderation/content-filters/', {params: params}) + } + return promise.then((response) => { + logger.default.info('Fetched a batch of ' + response.data.results.length + ' filters') + if (response.data.next) { + dispatch('fetchContentFilters', response.data.next) + } + response.data.results.forEach(result => { + commit('contentFilter', result) + }) + }) + }, + deleteContentFilter ({commit}, uuid) { + return axios.delete(`moderation/content-filters/${ uuid }/`).then((response) => { + commit('deleteContentFilter', uuid) + }) + } + } +} diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 311e0e9dcb5a114ce3133bdbba81993a5c34f110..152881e303447df70732b339a92a4e499b1bb0d3 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -271,3 +271,8 @@ canvas.color-thief { .ui.list .list.icon { padding-left: 0; } + + +.ui.dropdown .item[disabled] { + display: none; +}