Resolve "Hide an artist in the UI"
This commit is contained in:
parent
d4d4e60e39
commit
bdf83bd8ff
|
@ -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(
|
||||||
|
|
|
@ -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)
|
|
@ -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"
|
||||||
|
]
|
||||||
|
|
|
@ -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"]
|
|
@ -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"]:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
|
|
|
@ -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)
|
|
@ -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")}
|
||||||
|
),
|
||||||
|
]
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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]
|
|
@ -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]
|
|
@ -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]
|
|
@ -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
|
|
@ -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
|
|
@ -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]
|
|
@ -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]
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Allow artists hiding (#701)
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -103,6 +103,9 @@ export default {
|
||||||
watch: {
|
watch: {
|
||||||
offset () {
|
offset () {
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
|
},
|
||||||
|
"$store.state.moderation.lastUpdate": function () {
|
||||||
|
this.fetchData(this.url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
<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>
|
||||||
|
|
|
@ -176,6 +176,9 @@ export default {
|
||||||
query() {
|
query() {
|
||||||
this.updateQueryString()
|
this.updateQueryString()
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
|
},
|
||||||
|
"$store.state.moderation.lastUpdate": function () {
|
||||||
|
this.fetchData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -173,6 +173,9 @@ export default {
|
||||||
query() {
|
query() {
|
||||||
this.updateQueryString()
|
this.updateQueryString()
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
|
},
|
||||||
|
"$store.state.moderation.lastUpdate": function () {
|
||||||
|
this.fetchData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -104,6 +104,7 @@ export default {
|
||||||
let modules = [
|
let modules = [
|
||||||
'auth',
|
'auth',
|
||||||
'favorites',
|
'favorites',
|
||||||
|
'moderation',
|
||||||
'player',
|
'player',
|
||||||
'playlists',
|
'playlists',
|
||||||
'queue',
|
'queue',
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue