Resolve "Hide an artist in the UI"

This commit is contained in:
Eliot Berriot 2019-02-14 10:49:06 +01:00
parent d4d4e60e39
commit bdf83bd8ff
50 changed files with 1051 additions and 49 deletions

View File

@ -40,6 +40,12 @@ v1_patterns += [
r"^manage/", r"^manage/",
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"), include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
), ),
url(
r"^moderation/",
include(
("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
),
),
url( url(
r"^federation/", r"^federation/",
include( include(

View File

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

View File

@ -1,11 +1,10 @@
from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.moderation import filters as moderation_filters
from . import models from . import models
class TrackFavoriteFilter(filters.FilterSet): class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"] search_fields=["track__title", "track__artist__name", "track__album__title"]
) )
@ -13,3 +12,6 @@ class TrackFavoriteFilter(filters.FilterSet):
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ["user", "q"] fields = ["user", "q"]
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
"TRACK_FAVORITE"
]

View File

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

View File

@ -7,7 +7,7 @@ from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
from . import models, serializers from . import filters, models, serializers
class ListeningViewSet( class ListeningViewSet(
@ -25,6 +25,7 @@ class ListeningViewSet(
IsAuthenticatedOrReadOnly, IsAuthenticatedOrReadOnly,
] ]
owner_checks = ["write"] owner_checks = ["write"]
filterset_class = filters.ListeningFilter
def get_serializer_class(self): def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]: if self.request.method.lower() in ["head", "get", "options"]:

View File

@ -28,3 +28,10 @@ class InstancePolicyAdmin(admin.ModelAdmin):
"summary", "summary",
] ]
list_select_related = True 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

View File

@ -2,6 +2,8 @@ import factory
from funkwhale_api.factories import registry, NoUpdateOnCreate from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.federation import factories as federation_factories 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 @registry.register
@ -21,3 +23,17 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
for_actor = factory.Trait( for_actor = factory.Trait(
target_actor=factory.SubFactory(federation_factories.ActorFactory) 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)
)

View File

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

View File

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

View File

@ -73,3 +73,22 @@ class InstancePolicy(models.Model):
return {"type": "actor", "obj": self.target_actor} return {"type": "actor", "obj": self.target_actor}
if self.target_domain_id: if self.target_domain_id:
return {"type": "domain", "obj": self.target_domain} 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}

View File

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

View File

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

View File

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

View File

@ -2,12 +2,13 @@ from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.common import search from funkwhale_api.common import search
from funkwhale_api.moderation import filters as moderation_filters
from . import models from . import models
from . import utils from . import utils
class ArtistFilter(filters.FilterSet): class ArtistFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(search_fields=["name"]) q = fields.SearchFilter(search_fields=["name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
@ -17,13 +18,14 @@ class ArtistFilter(filters.FilterSet):
"name": ["exact", "iexact", "startswith", "icontains"], "name": ["exact", "iexact", "startswith", "icontains"],
"playable": "exact", "playable": "exact",
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value) 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"]) q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
@ -36,6 +38,7 @@ class TrackFilter(filters.FilterSet):
"album": ["exact"], "album": ["exact"],
"license": ["exact"], "license": ["exact"],
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
@ -85,13 +88,14 @@ class UploadFilter(filters.FilterSet):
return queryset.playable_by(actor, value) return queryset.playable_by(actor, value)
class AlbumFilter(filters.FilterSet): class AlbumFilter(moderation_filters.HiddenContentFilterSet):
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter(search_fields=["title", "artist__name"]) q = fields.SearchFilter(search_fields=["title", "artist__name"])
class Meta: class Meta:
model = models.Album model = models.Album
fields = ["playable", "q", "artist"] fields = ["playable", "q", "artist"]
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)

View File

@ -18,6 +18,7 @@ from taggit.models import Tag
from funkwhale_api.common import permissions as common_permissions from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils 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.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
@ -58,7 +59,7 @@ class TagViewSetMixin(object):
return queryset return queryset
class ArtistViewSet(viewsets.ReadOnlyModelViewSet): class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = models.Artist.objects.all() queryset = models.Artist.objects.all()
serializer_class = serializers.ArtistWithAlbumsSerializer serializer_class = serializers.ArtistWithAlbumsSerializer
permission_classes = [common_permissions.ConditionalAuthentication] 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 = ( queryset = (
models.Album.objects.all().order_by("artist", "release_date").select_related() models.Album.objects.all().order_by("artist", "release_date").select_related()
) )
@ -166,7 +167,9 @@ class LibraryViewSet(
return Response(serializer.data) 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. A simple ViewSet for viewing and editing accounts.
""" """

View File

@ -17,7 +17,7 @@ class PlaylistQuerySet(models.QuerySet):
def with_covers(self): def with_covers(self):
album_prefetch = models.Prefetch( 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_prefetch = models.Prefetch(
"track", "track",

View File

@ -117,9 +117,21 @@ class PlaylistSerializer(serializers.ModelSerializer):
except AttributeError: except AttributeError:
return [] 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 = [] covers = []
max_covers = 5 max_covers = 5
for plt in plts: for plt in plts:
if plt.track.album.artist_id in excluded_artists:
continue
url = plt.track.album.cover.crop["200x200"].url url = plt.track.album.cover.crop["200x200"].url
if url in covers: if url in covers:
continue continue

View File

@ -5,6 +5,7 @@ from django.db import connection
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag 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.music.models import Artist, Track
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
@ -43,7 +44,14 @@ class SessionRadio(SimpleRadio):
return self.session return self.session
def get_queryset(self, **kwargs): 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): def get_queryset_kwargs(self):
return {} return {}

View File

@ -13,6 +13,7 @@ import funkwhale_api
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.common import fields, preferences, utils as common_utils from funkwhale_api.common import fields, preferences, utils as common_utils
from funkwhale_api.favorites.models import TrackFavorite 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 models as music_models
from funkwhale_api.music import utils from funkwhale_api.music import utils
from funkwhale_api.music import views as music_views from funkwhale_api.music import views as music_views
@ -152,8 +153,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getArtists", url_path="getArtists",
) )
def get_artists(self, request, *args, **kwargs): def get_artists(self, request, *args, **kwargs):
artists = music_models.Artist.objects.all().playable_by( artists = (
utils.get_actor_from_request(request) 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 data = serializers.GetArtistsSerializer(artists).data
payload = {"artists": data} payload = {"artists": data}
@ -167,8 +174,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getIndexes", url_path="getIndexes",
) )
def get_indexes(self, request, *args, **kwargs): def get_indexes(self, request, *args, **kwargs):
artists = music_models.Artist.objects.all().playable_by( artists = (
utils.get_actor_from_request(request) 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 data = serializers.GetArtistsSerializer(artists).data
payload = {"indexes": data} payload = {"indexes": data}
@ -273,7 +286,11 @@ class SubsonicViewSet(viewsets.GenericViewSet):
def get_random_songs(self, request, *args, **kwargs): def get_random_songs(self, request, *args, **kwargs):
data = request.GET or request.POST data = request.GET or request.POST
actor = utils.get_actor_from_request(request) 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) queryset = queryset.playable_by(actor)
try: try:
size = int(data["size"]) size = int(data["size"])
@ -308,8 +325,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getAlbumList2", url_path="getAlbumList2",
) )
def get_album_list2(self, request, *args, **kwargs): def get_album_list2(self, request, *args, **kwargs):
queryset = music_models.Album.objects.with_tracks_count().order_by( queryset = (
"artist__name" 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 data = request.GET or request.POST
filterset = filters.AlbumList2FilterSet(data, queryset=queryset) filterset = filters.AlbumList2FilterSet(data, queryset=queryset)

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

@ -254,3 +254,27 @@ def test_similar_radio_track(factories):
factories["history.Listening"](track=expected_next, user=l1.user) factories["history.Listening"](track=expected_next, user=l1.user)
assert radio.pick(filter_playable=False) == expected_next 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]

View File

@ -7,6 +7,7 @@ from django.utils import timezone
from rest_framework.response import Response from rest_framework.response import Response
import funkwhale_api 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 models as music_models
from funkwhale_api.music import views as music_views from funkwhale_api.music import views as music_views
from funkwhale_api.subsonic import renderers, serializers from funkwhale_api.subsonic import renderers, serializers
@ -100,20 +101,31 @@ def test_ping(f, db, api_client):
def test_get_artists( def test_get_artists(
f, db, logged_in_api_client, factories, mocker, queryset_equal_queries 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") url = reverse("api:subsonic-get_artists")
assert url.endswith("getArtists") is True assert url.endswith("getArtists") is True
factories["music.Artist"].create_batch(size=3, playable=True) factories["music.Artist"].create_batch(size=3, playable=True)
playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by") 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 = { expected = {
"artists": serializers.GetArtistsSerializer( "artists": serializers.GetArtistsSerializer(
music_models.Artist.objects.all() music_models.Artist.objects.all().exclude(exclude_query)
).data ).data
} }
response = logged_in_api_client.get(url, {"f": f}) response = logged_in_api_client.get(url, {"f": f})
assert response.status_code == 200 assert response.status_code == 200
assert response.data == expected 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"]) @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( def test_get_indexes(
f, db, logged_in_api_client, factories, mocker, queryset_equal_queries 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") url = reverse("api:subsonic-get_indexes")
assert url.endswith("getIndexes") is True assert url.endswith("getIndexes") is True
factories["music.Artist"].create_batch(size=3, playable=True) factories["music.Artist"].create_batch(size=3, playable=True)
expected = { expected = {
"indexes": serializers.GetArtistsSerializer( "indexes": serializers.GetArtistsSerializer(
music_models.Artist.objects.all() music_models.Artist.objects.all().exclude(exclude_query)
).data ).data
} }
playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by") playable_by = mocker.spy(music_models.ArtistQuerySet, "playable_by")
@ -516,7 +536,10 @@ def test_get_indexes(
assert response.status_code == 200 assert response.status_code == 200
assert response.data == expected 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): def test_get_cover_art_album(factories, logged_in_api_client):

View File

@ -0,0 +1 @@
Allow artists hiding (#701)

View File

@ -5,3 +5,18 @@ Next release notes
Those release notes refer to the current development branch and are reset Those release notes refer to the current development branch and are reset
after each release. 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.

View File

@ -44,6 +44,7 @@
@show:shortcuts-modal="showShortcutsModal = !showShortcutsModal" @show:shortcuts-modal="showShortcutsModal = !showShortcutsModal"
></app-footer> ></app-footer>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> <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> <shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
<GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/> <GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/>
</template> </template>
@ -63,6 +64,7 @@ import ServiceMessages from '@/components/ServiceMessages'
import moment from 'moment' import moment from 'moment'
import locales from './locales' import locales from './locales'
import PlaylistModal from '@/components/playlists/PlaylistModal' import PlaylistModal from '@/components/playlists/PlaylistModal'
import FilterModal from '@/components/moderation/FilterModal'
import ShortcutsModal from '@/components/ShortcutsModal' import ShortcutsModal from '@/components/ShortcutsModal'
export default { export default {
@ -70,6 +72,7 @@ export default {
components: { components: {
Sidebar, Sidebar,
AppFooter, AppFooter,
FilterModal,
PlaylistModal, PlaylistModal,
ShortcutsModal, ShortcutsModal,
GlobalEvents, GlobalEvents,

View File

@ -259,6 +259,29 @@ export default {
? 0 ? 0
: container.clientHeight / 2 : container.clientHeight / 2
container.scrollTop = container.scrollTop - scrollBack 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: { watch: {
@ -274,6 +297,9 @@ export default {
if (this.selectedTab !== "queue") { if (this.selectedTab !== "queue") {
this.scrollToCurrent() this.scrollToCurrent()
} }
},
"$store.state.moderation.lastUpdate": function () {
this.applyContentFilters()
} }
} }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<span :title="title" :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]"> <span :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]">
<button <button
v-if="!dropdownOnly" v-if="!dropdownOnly"
:title="labels.playNow" :title="labels.playNow"
@ -9,8 +9,8 @@
<i :class="[playIconClass, 'icon']"></i> <i :class="[playIconClass, 'icon']"></i>
<template v-if="!discrete && !iconOnly"><slot><translate :translate-context="'*/Queue/Button/Label/Short, Verb'">Play</translate></slot></template> <template v-if="!discrete && !iconOnly"><slot><translate :translate-context="'*/Queue/Button/Label/Short, Verb'">Play</translate></slot></template>
</button> </button>
<div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"> <div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]">
<i :class="dropdownIconClasses.concat(['icon'])"></i> <i :class="dropdownIconClasses.concat(['icon'])" :title="title" ></i>
<div class="menu"> <div class="menu">
<button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue"> <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> <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"> <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> <i class="feed icon"></i><translate :translate-context="'*/Queue/Dropdown/Button/Label/Short'">Start radio</translate>
</button> </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>
</div> </div>
</span> </span>
@ -45,13 +48,13 @@ export default {
discrete: {type: Boolean, default: false}, discrete: {type: Boolean, default: false},
dropdownOnly: {type: Boolean, default: false}, dropdownOnly: {type: Boolean, default: false},
iconOnly: {type: Boolean, default: false}, iconOnly: {type: Boolean, default: false},
artist: {type: Number, required: false}, artist: {type: Object, required: false},
album: {type: Number, required: false}, album: {type: Object, required: false},
isPlayable: {type: Boolean, required: false, default: null} isPlayable: {type: Boolean, required: false, default: null}
}, },
data () { data () {
return { return {
isLoading: false isLoading: false,
} }
}, },
mounted () { mounted () {
@ -91,7 +94,7 @@ export default {
if (this.track) { if (this.track) {
return this.track.uploads && this.track.uploads.length > 0 return this.track.uploads && this.track.uploads.length > 0
} else if (this.artist) { } else if (this.artist) {
return this.albums.filter((a) => { return this.artist.albums.filter((a) => {
return a.is_playable === true return a.is_playable === true
}).length > 0 }).length > 0
} else if (this.tracks) { } else if (this.tracks) {
@ -100,9 +103,24 @@ export default {
}).length > 0 }).length > 0
} }
return false return false
},
filterableArtist () {
if (this.track) {
return this.track.artist
}
if (this.album) {
return this.album.artist
}
if (this.artist) {
return this.artist
}
} }
}, },
methods: { methods: {
filterArtist () {
this.$store.dispatch('moderation/hide', {type: 'artist', target: this.filterableArtist})
},
getTracksPage (page, params, resolve, tracks) { getTracksPage (page, params, resolve, tracks) {
if (page > 10) { if (page > 10) {
// it's 10 * 100 tracks already, let's stop here // it's 10 * 100 tracks already, let's stop here
@ -113,6 +131,7 @@ export default {
let self = this let self = this
params['page_size'] = 100 params['page_size'] = 100
params['page'] = page params['page'] = page
params['hidden'] = ''
tracks = tracks || [] tracks = tracks || []
axios.get('tracks/', {params: params}).then((response) => { axios.get('tracks/', {params: params}).then((response) => {
response.data.results.forEach(t => { response.data.results.forEach(t => {
@ -143,15 +162,27 @@ export default {
} else if (self.playlist) { } else if (self.playlist) {
let url = 'playlists/' + self.playlist.id + '/' let url = 'playlists/' + self.playlist.id + '/'
axios.get(url + 'tracks/').then((response) => { 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 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) { } 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) self.getTracksPage(1, params, resolve)
} else if (self.album) { } else if (self.album) {
let params = {'album': self.album, 'ordering': 'position'} let params = {'album': self.album.id, 'ordering': 'position'}
self.getTracksPage(1, params, resolve) self.getTracksPage(1, params, resolve)
} }
}) })
@ -192,7 +223,7 @@ export default {
content: this.$gettextInterpolate(msg, {count: tracks.length}), content: this.$gettextInterpolate(msg, {count: tracks.length}),
date: new Date() date: new Date()
}) })
} },
} }
} }
</script> </script>

View File

@ -38,6 +38,14 @@
v-if="$store.state.auth.authenticated" v-if="$store.state.auth.authenticated"
:class="['inverted']" :class="['inverted']"
:track="currentTrack"></track-playlist-icon> :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> </div>
</div> </div>
@ -353,13 +361,13 @@ export default {
let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track") let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track")
let unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute") let unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute")
let mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute") 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." "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." "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." "Looping on whole queue. Click to disable looping."
) )
let shuffle = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Shuffle your queue") let shuffle = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Shuffle your queue")

View File

@ -45,7 +45,7 @@
</div> </div>
</div> </div>
<div class="extra content"> <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> <translate :translate-context="'Content/Queue/Card.Button.Label/Short, Verb'">Play all</translate>
</play-button> </play-button>
<span> <span>

View File

@ -12,7 +12,7 @@
</div> </div>
<div class="card" v-for="album in albums" :key="album.id"> <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)"> <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>
<div class="content"> <div class="content">
<router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}"> <router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}">
@ -28,7 +28,7 @@
</div> </div>
<div class="extra content"> <div class="extra content">
<human-date class="left floated" :date="album.creation_date"></human-date> <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> </div>
</div> </div>
@ -101,6 +101,9 @@ export default {
watch: { watch: {
offset () { offset () {
this.fetchData() this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData('albums/')
} }
} }
} }

View File

@ -21,7 +21,7 @@
{{ album.tracks_count }} tracks {{ album.tracks_count }} tracks
</td> </td>
<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> </td>
</tr> </tr>
</tbody> </tbody>
@ -41,7 +41,7 @@
<i class="sound icon"></i> <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> <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> </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> <translate :translate-context="'Content/Queue/Button.Label/Short, Verb'">Play all</translate>
</play-button> </play-button>
</div> </div>

View File

@ -103,6 +103,9 @@ export default {
watch: { watch: {
offset () { offset () {
this.fetchData() this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData(this.url)
} }
} }
} }

View File

@ -29,8 +29,8 @@
</button> </button>
</form> </form>
</section> </section>
<div class="ui hidden divider"></div>
<section class="ui small text container"> <section class="ui small text container">
<div class="ui divider"></div>
<h2 class="ui header"> <h2 class="ui header">
<translate :translate-context="'Content/Settings/Title'">Avatar</translate> <translate :translate-context="'Content/Settings/Title'">Avatar</translate>
</h2> </h2>
@ -62,8 +62,9 @@
</div> </div>
</div> </div>
</section> </section>
<div class="ui hidden divider"></div>
<section class="ui small text container"> <section class="ui small text container">
<div class="ui divider"></div>
<h2 class="ui header"> <h2 class="ui header">
<translate :translate-context="'Content/Settings/Title/Verb'">Change my password</translate> <translate :translate-context="'Content/Settings/Title/Verb'">Change my password</translate>
</h2> </h2>
@ -107,6 +108,53 @@
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<subsonic-token-form /> <subsonic-token-form />
</section> </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>&nbsp;
<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> </div>
</main> </main>
</template> </template>

View File

@ -176,6 +176,9 @@ export default {
query() { query() {
this.updateQueryString() this.updateQueryString()
this.fetchData() this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
} }
} }
} }

View File

@ -23,7 +23,7 @@
</h2> </h2>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<radio-button type="artist" :object-id="artist.id"></radio-button> <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> <translate :translate-context="'Content/Artist/Button.Label/Verb'">Play all albums</translate>
</play-button> </play-button>
@ -37,6 +37,20 @@
</a> </a>
</div> </div>
</section> </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"> <section v-if="isLoadingAlbums" class="ui vertical stripe segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</section> </section>
@ -105,7 +119,7 @@ export default {
var self = this var self = this
this.isLoading = true this.isLoading = true
logger.default.debug('Fetching artist "' + this.id + '"') 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.tracks = response.data.results
self.totalTracks = response.data.count self.totalTracks = response.data.count
}) })
@ -115,7 +129,7 @@ export default {
self.isLoadingAlbums = true self.isLoadingAlbums = true
axios axios
.get("albums/", { .get("albums/", {
params: { artist: self.id, ordering: "-release_date" } params: { artist: self.id, ordering: "-release_date", hidden: '' }
}) })
.then(response => { .then(response => {
self.totalAlbums = response.data.count self.totalAlbums = response.data.count
@ -180,6 +194,12 @@ export default {
this.$store.getters["instance/absoluteUrl"](this.cover.original) + 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: { watch: {

View File

@ -173,6 +173,9 @@ export default {
query() { query() {
this.updateQueryString() this.updateQueryString()
this.fetchData() this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
} }
} }
} }

View File

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

View File

@ -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="!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 :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> <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 v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div> <div class="ui loader"></div>
</div> </div>
@ -71,6 +70,9 @@ export default {
watch: { watch: {
offset () { offset () {
this.fetchData() this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData(this.url)
} }
} }
} }

View File

@ -10,4 +10,5 @@ export default {
sortBy: require('lodash/sortBy'), sortBy: require('lodash/sortBy'),
throttle: require('lodash/throttle'), throttle: require('lodash/throttle'),
uniq: require('lodash/uniq'), uniq: require('lodash/uniq'),
remove: require('lodash/remove'),
} }

View File

@ -125,6 +125,7 @@ export default {
}) })
dispatch('ui/fetchUnreadNotifications', null, { root: true }) dispatch('ui/fetchUnreadNotifications', null, { root: true })
dispatch('favorites/fetch', null, { root: true }) dispatch('favorites/fetch', null, { root: true })
dispatch('moderation/fetchContentFilters', null, { root: true })
dispatch('playlists/fetchOwn', null, { root: true }) dispatch('playlists/fetchOwn', null, { root: true })
}, (response) => { }, (response) => {
logger.default.info('Error while fetching user profile') logger.default.info('Error while fetching user profile')

View File

@ -5,6 +5,7 @@ import createPersistedState from 'vuex-persistedstate'
import favorites from './favorites' import favorites from './favorites'
import auth from './auth' import auth from './auth'
import instance from './instance' import instance from './instance'
import moderation from './moderation'
import queue from './queue' import queue from './queue'
import radios from './radios' import radios from './radios'
import player from './player' import player from './player'
@ -19,6 +20,7 @@ export default new Vuex.Store({
auth, auth,
favorites, favorites,
instance, instance,
moderation,
queue, queue,
radios, radios,
playlists, playlists,

View File

@ -104,6 +104,7 @@ export default {
let modules = [ let modules = [
'auth', 'auth',
'favorites', 'favorites',
'moderation',
'player', 'player',
'playlists', 'playlists',
'queue', 'queue',

View File

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

View File

@ -271,3 +271,8 @@ canvas.color-thief {
.ui.list .list.icon { .ui.list .list.icon {
padding-left: 0; padding-left: 0;
} }
.ui.dropdown .item[disabled] {
display: none;
}