See #170: admin UI for channels, reporting channels
This commit is contained in:
parent
ae52969efe
commit
102c90d499
|
@ -69,6 +69,15 @@ class Channel(models.Model):
|
|||
|
||||
objects = ChannelQuerySet.as_manager()
|
||||
|
||||
@property
|
||||
def fid(self):
|
||||
if not self.is_external_rss:
|
||||
return self.actor.fid
|
||||
|
||||
@property
|
||||
def is_external_rss(self):
|
||||
return self.actor.preferred_username.startswith("rssfeed-")
|
||||
|
||||
def get_absolute_url(self):
|
||||
suffix = self.uuid
|
||||
if self.actor.is_local:
|
||||
|
@ -78,9 +87,7 @@ class Channel(models.Model):
|
|||
return federation_utils.full_url("/channels/{}".format(suffix))
|
||||
|
||||
def get_rss_url(self):
|
||||
if not self.artist.is_local or self.actor.preferred_username.startswith(
|
||||
"rssfeed-"
|
||||
):
|
||||
if not self.artist.is_local or self.is_external_rss:
|
||||
return self.rss_url
|
||||
|
||||
return federation_utils.full_url(
|
||||
|
@ -90,10 +97,6 @@ class Channel(models.Model):
|
|||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def fid(self):
|
||||
return self.actor.fid
|
||||
|
||||
|
||||
def generate_actor(username, **kwargs):
|
||||
actor_data = user_models.get_actor_data(username, **kwargs)
|
||||
|
|
|
@ -145,6 +145,7 @@ class Domain(models.Model):
|
|||
actors=models.Count("actors", distinct=True),
|
||||
outbox_activities=models.Count("actors__outbox_activities", distinct=True),
|
||||
libraries=models.Count("actors__libraries", distinct=True),
|
||||
channels=models.Count("actors__owned_channels", distinct=True),
|
||||
received_library_follows=models.Count(
|
||||
"actors__libraries__received_follows", distinct=True
|
||||
),
|
||||
|
@ -283,6 +284,7 @@ class Actor(models.Model):
|
|||
data = Actor.objects.filter(pk=self.pk).aggregate(
|
||||
outbox_activities=models.Count("outbox_activities", distinct=True),
|
||||
libraries=models.Count("libraries", distinct=True),
|
||||
channels=models.Count("owned_channels", distinct=True),
|
||||
received_library_follows=models.Count(
|
||||
"libraries__received_follows", distinct=True
|
||||
),
|
||||
|
|
|
@ -482,6 +482,8 @@ def inbox_flag(payload, context):
|
|||
@outbox.register({"type": "Flag"})
|
||||
def outbox_flag(context):
|
||||
report = context["report"]
|
||||
if not report.target or not report.target.fid:
|
||||
return
|
||||
actor = actors.get_service_actor()
|
||||
serializer = serializers.FlagSerializer(report)
|
||||
yield {
|
||||
|
|
|
@ -266,5 +266,11 @@ def get_object_by_fid(fid, local=None):
|
|||
|
||||
if not result:
|
||||
raise ObjectDoesNotExist()
|
||||
model = apps.get_model(*result["__type"].split("."))
|
||||
instance = model.objects.get(fid=fid)
|
||||
if model._meta.label == "federation.Actor":
|
||||
channel = instance.get_channel()
|
||||
if channel:
|
||||
return channel
|
||||
|
||||
return apps.get_model(*result["__type"].split(".")).objects.get(fid=fid)
|
||||
return instance
|
||||
|
|
|
@ -8,6 +8,7 @@ from funkwhale_api.common import fields
|
|||
from funkwhale_api.common import filters as common_filters
|
||||
from funkwhale_api.common import search
|
||||
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.moderation import models as moderation_models
|
||||
|
@ -34,6 +35,34 @@ def get_actor_filter(actor_field):
|
|||
return {"field": ActorField(), "handler": handler}
|
||||
|
||||
|
||||
class ManageChannelFilterSet(filters.FilterSet):
|
||||
q = fields.SmartSearchFilter(
|
||||
config=search.SearchConfig(
|
||||
search_fields={
|
||||
"name": {"to": "artist__name"},
|
||||
"username": {"to": "artist__name"},
|
||||
"fid": {"to": "artist__fid"},
|
||||
"rss": {"to": "rss_url"},
|
||||
},
|
||||
filter_fields={
|
||||
"uuid": {"to": "uuid"},
|
||||
"category": {"to": "artist__content_category"},
|
||||
"domain": {
|
||||
"handler": lambda v: federation_utils.get_domain_query_from_url(
|
||||
v, url_field="attributed_to__fid"
|
||||
)
|
||||
},
|
||||
"tag": {"to": "artist__tagged_items__tag__name", "distinct": True},
|
||||
"account": get_actor_filter("attributed_to"),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = audio_models.Channel
|
||||
fields = ["q"]
|
||||
|
||||
|
||||
class ManageArtistFilterSet(filters.FilterSet):
|
||||
q = fields.SmartSearchFilter(
|
||||
config=search.SearchConfig(
|
||||
|
@ -52,6 +81,7 @@ class ManageArtistFilterSet(filters.FilterSet):
|
|||
"field": forms.IntegerField(),
|
||||
"distinct": True,
|
||||
},
|
||||
"category": {"to": "content_category"},
|
||||
"tag": {"to": "tagged_items__tag__name", "distinct": True},
|
||||
},
|
||||
)
|
||||
|
@ -59,7 +89,7 @@ class ManageArtistFilterSet(filters.FilterSet):
|
|||
|
||||
class Meta:
|
||||
model = music_models.Artist
|
||||
fields = ["q", "name", "mbid", "fid"]
|
||||
fields = ["q", "name", "mbid", "fid", "content_category"]
|
||||
|
||||
|
||||
class ManageAlbumFilterSet(filters.FilterSet):
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.db import transaction
|
|||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
from funkwhale_api.common import fields as common_fields
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
|
@ -386,26 +387,39 @@ class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer):
|
|||
class ManageArtistSerializer(
|
||||
music_serializers.OptionalDescriptionMixin, ManageBaseArtistSerializer
|
||||
):
|
||||
albums = ManageNestedAlbumSerializer(many=True)
|
||||
tracks = ManageNestedTrackSerializer(many=True)
|
||||
attributed_to = ManageBaseActorSerializer()
|
||||
tags = serializers.SerializerMethodField()
|
||||
tracks_count = serializers.SerializerMethodField()
|
||||
albums_count = serializers.SerializerMethodField()
|
||||
channel = serializers.SerializerMethodField()
|
||||
cover = music_serializers.cover_field
|
||||
|
||||
class Meta:
|
||||
model = music_models.Artist
|
||||
fields = ManageBaseArtistSerializer.Meta.fields + [
|
||||
"albums",
|
||||
"tracks",
|
||||
"tracks_count",
|
||||
"albums_count",
|
||||
"attributed_to",
|
||||
"tags",
|
||||
"cover",
|
||||
"channel",
|
||||
"content_category",
|
||||
]
|
||||
|
||||
def get_tracks_count(self, obj):
|
||||
return getattr(obj, "_tracks_count", None)
|
||||
|
||||
def get_albums_count(self, obj):
|
||||
return getattr(obj, "_albums_count", None)
|
||||
|
||||
def get_tags(self, obj):
|
||||
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
|
||||
return [ti.tag.name for ti in tagged_items]
|
||||
|
||||
def get_channel(self, obj):
|
||||
if "channel" in obj._state.fields_cache and obj.get_channel():
|
||||
return str(obj.channel.uuid)
|
||||
|
||||
|
||||
class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
|
||||
pass
|
||||
|
@ -743,3 +757,23 @@ class ManageUserRequestSerializer(serializers.ModelSerializer):
|
|||
def get_notes(self, o):
|
||||
notes = getattr(o, "_prefetched_notes", [])
|
||||
return ManageBaseNoteSerializer(notes, many=True).data
|
||||
|
||||
|
||||
class ManageChannelSerializer(serializers.ModelSerializer):
|
||||
attributed_to = ManageBaseActorSerializer()
|
||||
actor = ManageBaseActorSerializer()
|
||||
artist = ManageArtistSerializer()
|
||||
|
||||
class Meta:
|
||||
model = audio_models.Channel
|
||||
fields = [
|
||||
"id",
|
||||
"uuid",
|
||||
"creation_date",
|
||||
"artist",
|
||||
"attributed_to",
|
||||
"actor",
|
||||
"rss_url",
|
||||
"metadata",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
|
|
@ -27,6 +27,7 @@ users_router.register(r"invitations", views.ManageInvitationViewSet, "invitation
|
|||
|
||||
other_router = routers.OptionalSlashRouter()
|
||||
other_router.register(r"accounts", views.ManageActorViewSet, "accounts")
|
||||
other_router.register(r"channels", views.ManageChannelViewSet, "channels")
|
||||
other_router.register(r"tags", views.ManageTagViewSet, "tags")
|
||||
|
||||
urlpatterns = [
|
||||
|
|
|
@ -6,12 +6,15 @@ from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery
|
|||
from django.db.models.functions import Coalesce, Length
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
|
||||
from funkwhale_api.common import models as common_models
|
||||
from funkwhale_api.common import preferences, decorators
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.favorites import models as favorites_models
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import tasks as federation_tasks
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.history import models as history_models
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import views as music_views
|
||||
|
@ -25,37 +28,39 @@ from funkwhale_api.users import models as users_models
|
|||
from . import filters, serializers
|
||||
|
||||
|
||||
def get_stats(tracks, target):
|
||||
data = {}
|
||||
def get_stats(tracks, target, ignore_fields=[]):
|
||||
tracks = list(tracks.values_list("pk", flat=True))
|
||||
uploads = music_models.Upload.objects.filter(track__in=tracks)
|
||||
data["listenings"] = history_models.Listening.objects.filter(
|
||||
track__in=tracks
|
||||
).count()
|
||||
data["mutations"] = common_models.Mutation.objects.get_for_target(target).count()
|
||||
data["playlists"] = (
|
||||
playlists_models.PlaylistTrack.objects.filter(track__in=tracks)
|
||||
.values_list("playlist", flat=True)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
data["track_favorites"] = favorites_models.TrackFavorite.objects.filter(
|
||||
track__in=tracks
|
||||
).count()
|
||||
data["libraries"] = (
|
||||
uploads.filter(library__channel=None)
|
||||
.values_list("library", flat=True)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
data["channels"] = (
|
||||
uploads.exclude(library__channel=None)
|
||||
.values_list("library", flat=True)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
data["uploads"] = uploads.count()
|
||||
data["reports"] = moderation_models.Report.objects.get_for_target(target).count()
|
||||
fields = {
|
||||
"listenings": history_models.Listening.objects.filter(track__in=tracks),
|
||||
"mutations": common_models.Mutation.objects.get_for_target(target),
|
||||
"playlists": (
|
||||
playlists_models.PlaylistTrack.objects.filter(track__in=tracks)
|
||||
.values_list("playlist", flat=True)
|
||||
.distinct()
|
||||
),
|
||||
"track_favorites": (
|
||||
favorites_models.TrackFavorite.objects.filter(track__in=tracks)
|
||||
),
|
||||
"libraries": (
|
||||
uploads.filter(library__channel=None)
|
||||
.values_list("library", flat=True)
|
||||
.distinct()
|
||||
),
|
||||
"channels": (
|
||||
uploads.exclude(library__channel=None)
|
||||
.values_list("library", flat=True)
|
||||
.distinct()
|
||||
),
|
||||
"uploads": uploads,
|
||||
"reports": moderation_models.Report.objects.get_for_target(target),
|
||||
}
|
||||
data = {}
|
||||
for key, qs in fields.items():
|
||||
if key in ignore_fields:
|
||||
continue
|
||||
data[key] = qs.count()
|
||||
|
||||
data.update(get_media_stats(uploads))
|
||||
return data
|
||||
|
||||
|
@ -78,17 +83,10 @@ class ManageArtistViewSet(
|
|||
queryset = (
|
||||
music_models.Artist.objects.all()
|
||||
.order_by("-id")
|
||||
.select_related("attributed_to", "attachment_cover",)
|
||||
.prefetch_related(
|
||||
"tracks",
|
||||
Prefetch(
|
||||
"albums",
|
||||
queryset=music_models.Album.objects.select_related(
|
||||
"attachment_cover"
|
||||
).annotate(tracks_count=Count("tracks")),
|
||||
),
|
||||
music_views.TAG_PREFETCH,
|
||||
)
|
||||
.select_related("attributed_to", "attachment_cover", "channel")
|
||||
.annotate(_tracks_count=Count("tracks"))
|
||||
.annotate(_albums_count=Count("albums"))
|
||||
.prefetch_related(music_views.TAG_PREFETCH)
|
||||
)
|
||||
serializer_class = serializers.ManageArtistSerializer
|
||||
filterset_class = filters.ManageArtistFilterSet
|
||||
|
@ -661,3 +659,64 @@ class ManageUserRequestViewSet(
|
|||
)
|
||||
else:
|
||||
serializer.save()
|
||||
|
||||
|
||||
class ManageChannelViewSet(
|
||||
MultipleLookupDetailMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
|
||||
url_lookups = [
|
||||
{
|
||||
"lookup_field": "uuid",
|
||||
"validator": serializers.serializers.UUIDField().to_internal_value,
|
||||
},
|
||||
{
|
||||
"lookup_field": "username",
|
||||
"validator": federation_utils.get_actor_data_from_username,
|
||||
"get_query": lambda v: Q(
|
||||
actor__domain=v["domain"],
|
||||
actor__preferred_username__iexact=v["username"],
|
||||
),
|
||||
},
|
||||
]
|
||||
queryset = (
|
||||
audio_models.Channel.objects.all()
|
||||
.order_by("-id")
|
||||
.select_related("attributed_to", "actor",)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"artist",
|
||||
queryset=(
|
||||
music_models.Artist.objects.all()
|
||||
.order_by("-id")
|
||||
.select_related("attributed_to", "attachment_cover", "channel")
|
||||
.annotate(_tracks_count=Count("tracks"))
|
||||
.annotate(_albums_count=Count("albums"))
|
||||
.prefetch_related(music_views.TAG_PREFETCH)
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
serializer_class = serializers.ManageChannelSerializer
|
||||
filterset_class = filters.ManageChannelFilterSet
|
||||
required_scope = "instance:libraries"
|
||||
ordering_fields = ["creation_date", "name"]
|
||||
|
||||
@rest_decorators.action(methods=["get"], detail=True)
|
||||
def stats(self, request, *args, **kwargs):
|
||||
channel = self.get_object()
|
||||
tracks = music_models.Track.objects.filter(
|
||||
Q(artist=channel.artist) | Q(album__artist=channel.artist)
|
||||
)
|
||||
data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"])
|
||||
data["follows"] = channel.actor.received_follows.count()
|
||||
return response.Response(data, status=200)
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["description"] = self.action in ["retrieve", "create", "update"]
|
||||
return context
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||
import persisting_theory
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
from funkwhale_api.common import fields as common_fields
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
|
@ -61,20 +62,36 @@ class UserFilterSerializer(serializers.ModelSerializer):
|
|||
state_serializers = persisting_theory.Registry()
|
||||
|
||||
|
||||
class DescriptionStateMixin(object):
|
||||
def get_description(self, o):
|
||||
if o.description:
|
||||
return o.description.text
|
||||
|
||||
|
||||
TAGS_FIELD = serializers.ListField(source="get_tags")
|
||||
|
||||
|
||||
@state_serializers.register(name="music.Artist")
|
||||
class ArtistStateSerializer(serializers.ModelSerializer):
|
||||
class ArtistStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
|
||||
tags = TAGS_FIELD
|
||||
|
||||
class Meta:
|
||||
model = music_models.Artist
|
||||
fields = ["id", "name", "mbid", "fid", "creation_date", "uuid", "tags"]
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"mbid",
|
||||
"fid",
|
||||
"creation_date",
|
||||
"uuid",
|
||||
"tags",
|
||||
"content_category",
|
||||
"description",
|
||||
]
|
||||
|
||||
|
||||
@state_serializers.register(name="music.Album")
|
||||
class AlbumStateSerializer(serializers.ModelSerializer):
|
||||
class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
|
||||
tags = TAGS_FIELD
|
||||
artist = ArtistStateSerializer()
|
||||
|
||||
|
@ -90,11 +107,12 @@ class AlbumStateSerializer(serializers.ModelSerializer):
|
|||
"artist",
|
||||
"release_date",
|
||||
"tags",
|
||||
"description",
|
||||
]
|
||||
|
||||
|
||||
@state_serializers.register(name="music.Track")
|
||||
class TrackStateSerializer(serializers.ModelSerializer):
|
||||
class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
|
||||
tags = TAGS_FIELD
|
||||
artist = ArtistStateSerializer()
|
||||
album = AlbumStateSerializer()
|
||||
|
@ -115,6 +133,7 @@ class TrackStateSerializer(serializers.ModelSerializer):
|
|||
"license",
|
||||
"copyright",
|
||||
"tags",
|
||||
"description",
|
||||
]
|
||||
|
||||
|
||||
|
@ -156,6 +175,36 @@ class ActorStateSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
@state_serializers.register(name="audio.Channel")
|
||||
class ChannelStateSerializer(serializers.ModelSerializer):
|
||||
rss_url = serializers.CharField(source="get_rss_url")
|
||||
name = serializers.CharField(source="artist.name")
|
||||
full_username = serializers.CharField(source="actor.full_username")
|
||||
domain = serializers.CharField(source="actor.domain_id")
|
||||
description = serializers.SerializerMethodField()
|
||||
tags = serializers.ListField(source="artist.get_tags")
|
||||
content_category = serializers.CharField(source="artist.content_category")
|
||||
|
||||
class Meta:
|
||||
model = audio_models.Channel
|
||||
fields = [
|
||||
"uuid",
|
||||
"name",
|
||||
"rss_url",
|
||||
"metadata",
|
||||
"full_username",
|
||||
"description",
|
||||
"domain",
|
||||
"creation_date",
|
||||
"tags",
|
||||
"content_category",
|
||||
]
|
||||
|
||||
def get_description(self, o):
|
||||
if o.artist.description:
|
||||
return o.artist.description.text
|
||||
|
||||
|
||||
def get_actor_query(attr, value):
|
||||
data = federation_utils.get_actor_data_from_username(value)
|
||||
return federation_utils.get_actor_from_username_data_query(None, data)
|
||||
|
@ -163,6 +212,7 @@ def get_actor_query(attr, value):
|
|||
|
||||
def get_target_owner(target):
|
||||
mapping = {
|
||||
audio_models.Channel: lambda t: t.attributed_to,
|
||||
music_models.Artist: lambda t: t.attributed_to,
|
||||
music_models.Album: lambda t: t.attributed_to,
|
||||
music_models.Track: lambda t: t.attributed_to,
|
||||
|
@ -175,6 +225,11 @@ def get_target_owner(target):
|
|||
|
||||
|
||||
TARGET_CONFIG = {
|
||||
"channel": {
|
||||
"queryset": audio_models.Channel.objects.all(),
|
||||
"id_attr": "uuid",
|
||||
"id_field": serializers.UUIDField(),
|
||||
},
|
||||
"artist": {"queryset": music_models.Artist.objects.all()},
|
||||
"album": {"queryset": music_models.Album.objects.all()},
|
||||
"track": {"queryset": music_models.Track.objects.all()},
|
||||
|
|
|
@ -126,6 +126,7 @@ def test_domain_stats(factories):
|
|||
"libraries": 0,
|
||||
"tracks": 0,
|
||||
"albums": 0,
|
||||
"channels": 0,
|
||||
"uploads": 0,
|
||||
"artists": 0,
|
||||
"outbox_activities": 0,
|
||||
|
@ -148,6 +149,7 @@ def test_actor_stats(factories):
|
|||
"uploads": 0,
|
||||
"artists": 0,
|
||||
"reports": 0,
|
||||
"channels": 0,
|
||||
"requests": 0,
|
||||
"outbox_activities": 0,
|
||||
"received_library_follows": 0,
|
||||
|
|
|
@ -844,6 +844,7 @@ def test_inbox_delete_actor_doesnt_delete_local_actor(factories):
|
|||
@pytest.mark.parametrize(
|
||||
"factory_name, factory_kwargs",
|
||||
[
|
||||
("audio.Channel", {"local": True}),
|
||||
("federation.Actor", {"local": True}),
|
||||
("music.Artist", {"local": True}),
|
||||
("music.Album", {"local": True}),
|
||||
|
@ -885,6 +886,7 @@ def test_inbox_flag(factory_name, factory_kwargs, factories, mocker):
|
|||
@pytest.mark.parametrize(
|
||||
"factory_name, factory_kwargs",
|
||||
[
|
||||
("audio.Channel", {"local": True}),
|
||||
("federation.Actor", {"local": True}),
|
||||
("music.Artist", {"local": True}),
|
||||
("music.Album", {"local": True}),
|
||||
|
|
|
@ -207,3 +207,9 @@ def test_get_obj_by_fid(factory_name, factories):
|
|||
obj = factories[factory_name]()
|
||||
factories[factory_name]()
|
||||
assert utils.get_object_by_fid(obj.fid) == obj
|
||||
|
||||
|
||||
def test_get_channel_by_fid(factories):
|
||||
obj = factories["audio.Channel"]()
|
||||
factories["audio.Channel"]()
|
||||
assert utils.get_object_by_fid(obj.actor.fid) == obj
|
||||
|
|
|
@ -287,8 +287,11 @@ def test_instance_policy_serializer_purges_target_actor(
|
|||
|
||||
def test_manage_artist_serializer(factories, now, to_api_date):
|
||||
artist = factories["music.Artist"](attributed=True, with_cover=True)
|
||||
track = factories["music.Track"](artist=artist)
|
||||
album = factories["music.Album"](artist=artist)
|
||||
channel = factories["audio.Channel"](artist=artist)
|
||||
# put channel in cache
|
||||
artist.get_channel()
|
||||
setattr(artist, "_tracks_count", 12)
|
||||
setattr(artist, "_albums_count", 13)
|
||||
expected = {
|
||||
"id": artist.id,
|
||||
"domain": artist.domain_name,
|
||||
|
@ -297,12 +300,14 @@ def test_manage_artist_serializer(factories, now, to_api_date):
|
|||
"name": artist.name,
|
||||
"mbid": artist.mbid,
|
||||
"creation_date": to_api_date(artist.creation_date),
|
||||
"albums": [serializers.ManageNestedAlbumSerializer(album).data],
|
||||
"tracks": [serializers.ManageNestedTrackSerializer(track).data],
|
||||
"tracks_count": 12,
|
||||
"albums_count": 13,
|
||||
"attributed_to": serializers.ManageBaseActorSerializer(
|
||||
artist.attributed_to
|
||||
).data,
|
||||
"tags": [],
|
||||
"channel": str(channel.uuid),
|
||||
"content_category": artist.content_category,
|
||||
"cover": common_serializers.AttachmentSerializer(artist.attachment_cover).data,
|
||||
}
|
||||
s = serializers.ManageArtistSerializer(artist)
|
||||
|
@ -585,3 +590,22 @@ def test_manage_user_request_serializer(factories, to_api_date):
|
|||
s = serializers.ManageUserRequestSerializer(user_request)
|
||||
|
||||
assert s.data == expected
|
||||
|
||||
|
||||
def test_manage_channel_serializer(factories, now, to_api_date):
|
||||
channel = factories["audio.Channel"]()
|
||||
expected = {
|
||||
"id": channel.id,
|
||||
"uuid": channel.uuid,
|
||||
"artist": serializers.ManageArtistSerializer(channel.artist).data,
|
||||
"actor": serializers.ManageBaseActorSerializer(channel.actor).data,
|
||||
"attributed_to": serializers.ManageBaseActorSerializer(
|
||||
channel.attributed_to
|
||||
).data,
|
||||
"creation_date": to_api_date(channel.creation_date),
|
||||
"rss_url": channel.get_rss_url(),
|
||||
"metadata": channel.metadata,
|
||||
}
|
||||
s = serializers.ManageChannelSerializer(channel)
|
||||
|
||||
assert s.data == expected
|
||||
|
|
|
@ -599,3 +599,50 @@ def test_user_request_update_status_assigns(factories, superuser_api_client, moc
|
|||
new_status="refused",
|
||||
old_status="pending",
|
||||
)
|
||||
|
||||
|
||||
def test_channel_list(factories, superuser_api_client, settings):
|
||||
channel = factories["audio.Channel"]()
|
||||
url = reverse("api:v1:manage:channels-list")
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == channel.id
|
||||
|
||||
|
||||
def test_channel_detail(factories, superuser_api_client):
|
||||
channel = factories["audio.Channel"]()
|
||||
url = reverse("api:v1:manage:channels-detail", kwargs={"composite": channel.uuid})
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == channel.id
|
||||
|
||||
|
||||
def test_channel_delete(factories, superuser_api_client, mocker):
|
||||
channel = factories["audio.Channel"]()
|
||||
url = reverse("api:v1:manage:channels-detail", kwargs={"composite": channel.uuid})
|
||||
response = superuser_api_client.delete(url)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_channel_detail_stats(factories, superuser_api_client):
|
||||
channel = factories["audio.Channel"]()
|
||||
url = reverse("api:v1:manage:channels-stats", kwargs={"composite": channel.uuid})
|
||||
response = superuser_api_client.get(url)
|
||||
expected = {
|
||||
"uploads": 0,
|
||||
"playlists": 0,
|
||||
"listenings": 0,
|
||||
"mutations": 0,
|
||||
"reports": 0,
|
||||
"follows": 0,
|
||||
"track_favorites": 0,
|
||||
"media_total_size": 0,
|
||||
"media_downloaded_size": 0,
|
||||
}
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected
|
||||
|
|
|
@ -52,6 +52,7 @@ def test_user_filter_serializer_save(factories):
|
|||
"full_username",
|
||||
serializers.ActorStateSerializer,
|
||||
),
|
||||
("audio.Channel", "channel", "uuid", serializers.ChannelStateSerializer),
|
||||
],
|
||||
)
|
||||
def test_report_federated_entity_serializer_save(
|
||||
|
@ -161,6 +162,7 @@ def test_report_serializer_save_anonymous(factories, mocker):
|
|||
("music.Library", {}, "actor"),
|
||||
("playlists.Playlist", {"user__with_actor": True}, "user.actor"),
|
||||
("federation.Actor", {}, "self"),
|
||||
("audio.Channel", {}, "attributed_to"),
|
||||
],
|
||||
)
|
||||
def test_get_target_owner(factory_name, factory_kwargs, owner_field, factories):
|
||||
|
|
|
@ -406,9 +406,9 @@ export default {
|
|||
},
|
||||
'serviceWorker.updateAvailable': {
|
||||
handler (v) {
|
||||
// if (!v) {
|
||||
// return
|
||||
// }
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
let self = this
|
||||
this.$store.commit('ui/addMessage', {
|
||||
content: this.$pgettext("App/Message/Paragraph", "A new version of the app is available."),
|
||||
|
|
|
@ -30,7 +30,11 @@
|
|||
:title="updatedTitle">
|
||||
{{ object.artist.modification_date | fromNow }}
|
||||
</time>
|
||||
<play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button>
|
||||
<play-button
|
||||
class="right floated basic icon"
|
||||
:dropdown-only="true"
|
||||
:is-playable="true"
|
||||
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist" :channel="object" :account="object.attributed_to"></play-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
<i class="eye slash outline icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate>
|
||||
</button>
|
||||
<button
|
||||
v-for="obj in getReportableObjs({track, album, artist, playlist, account})"
|
||||
v-for="obj in getReportableObjs({track, album, artist, playlist, account, channel})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
class="item basic"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||
|
@ -69,6 +69,7 @@ export default {
|
|||
artist: {type: Object, required: false},
|
||||
album: {type: Object, required: false},
|
||||
library: {type: Object, required: false},
|
||||
channel: {type: Object, required: false},
|
||||
isPlayable: {type: Boolean, required: false, default: null}
|
||||
},
|
||||
data () {
|
||||
|
|
|
@ -0,0 +1,224 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui inline form">
|
||||
<div class="fields">
|
||||
<div class="ui six wide field">
|
||||
<label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label>
|
||||
<form @submit.prevent="search.query = $refs.search.value">
|
||||
<input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
|
||||
</form>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><translate translate-context="*/*/*">Category</translate></label>
|
||||
<select class="ui dropdown" @change="addSearchToken('category', $event.target.value)" :value="getTokenValue('category', '')">
|
||||
<option value=""><translate translate-context="Content/*/Dropdown">All</translate></option>
|
||||
<option value="podcast">{{ sharedLabels.fields.content_category.choices.podcast }}</option>
|
||||
<option value="music">{{ sharedLabels.fields.content_category.choices.music }}</option>
|
||||
<option value="other">{{ sharedLabels.fields.content_category.choices.other }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
|
||||
<select class="ui dropdown" v-model="ordering">
|
||||
<option v-for="option in orderingOptions" :value="option[0]">
|
||||
{{ sharedLabels.filters[option[1]] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
|
||||
<option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dimmable">
|
||||
<div v-if="isLoading" class="ui active inverted dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
<action-table
|
||||
v-if="result"
|
||||
@action-launched="fetchData"
|
||||
:objects-data="result"
|
||||
:actions="actions"
|
||||
action-url="manage/library/artists/action/"
|
||||
:filters="actionFilters">
|
||||
<template slot="header-cells">
|
||||
<th><translate translate-context="*/*/*/Noun">Name</translate></th>
|
||||
<th><translate translate-context="*/*/*/Noun">Account</translate></th>
|
||||
<th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th>
|
||||
<th><translate translate-context="*/*/*">Albums</translate></th>
|
||||
<th><translate translate-context="*/*/*">Tracks</translate></th>
|
||||
<th><translate translate-context="Content/*/*/Noun">Creation date</translate></th>
|
||||
</template>
|
||||
<template slot="row-cells" slot-scope="scope">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.channels.detail', params: {id: scope.obj.actor.full_username }}">{{ scope.obj.artist.name }}</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.attributed_to.full_username }}">
|
||||
<i class="wrench icon"></i>
|
||||
</router-link>
|
||||
<span role="button" class="discrete link" @click="addSearchToken('account', scope.obj.attributed_to.full_username)" :title="scope.obj.attributed_to.full_username">{{ scope.obj.attributed_to.preferred_username }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="!scope.obj.is_local">
|
||||
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.attributed_to.domain }}">
|
||||
<i class="wrench icon"></i>
|
||||
</router-link>
|
||||
<span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.attributed_to.domain)" :title="scope.obj.attributed_to.domain">{{ scope.obj.attributed_to.domain }}</span>
|
||||
</template>
|
||||
<span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.attributed_to.domain)">
|
||||
<i class="home icon"></i>
|
||||
<translate translate-context="Content/Moderation/*/Short, Noun">Local</translate>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ scope.obj.artist.albums_count }}
|
||||
</td>
|
||||
<td>
|
||||
{{ scope.obj.artist.tracks_count }}
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="scope.obj.creation_date"></human-date>
|
||||
</td>
|
||||
</template>
|
||||
</action-table>
|
||||
</div>
|
||||
<div>
|
||||
<pagination
|
||||
v-if="result && result.count > paginateBy"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
|
||||
<span v-if="result && result.results.length > 0">
|
||||
<translate translate-context="Content/*/Paragraph"
|
||||
:translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}">
|
||||
Showing results %{ start }-%{ end } on %{ total }
|
||||
</translate>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import _ from '@/lodash'
|
||||
import time from '@/utils/time'
|
||||
import {normalizeQuery, parseTokens} from '@/search'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import ActionTable from '@/components/common/ActionTable'
|
||||
import OrderingMixin from '@/components/mixins/Ordering'
|
||||
import TranslationsMixin from '@/components/mixins/Translations'
|
||||
import SmartSearchMixin from '@/components/mixins/SmartSearch'
|
||||
|
||||
|
||||
export default {
|
||||
mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
|
||||
props: {
|
||||
filters: {type: Object, required: false},
|
||||
},
|
||||
components: {
|
||||
Pagination,
|
||||
ActionTable
|
||||
},
|
||||
data () {
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||
return {
|
||||
time,
|
||||
isLoading: false,
|
||||
result: null,
|
||||
page: 1,
|
||||
paginateBy: 50,
|
||||
search: {
|
||||
query: this.defaultQuery,
|
||||
tokens: parseTokens(normalizeQuery(this.defaultQuery))
|
||||
},
|
||||
orderingDirection: defaultOrdering.direction || '+',
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'creation_date'],
|
||||
["name", "name"],
|
||||
]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
let params = _.merge({
|
||||
'page': this.page,
|
||||
'page_size': this.paginateBy,
|
||||
'q': this.search.query,
|
||||
'ordering': this.getOrderingAsString()
|
||||
}, this.filters)
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.checked = []
|
||||
axios.get('/manage/channels/', {params: params}).then((response) => {
|
||||
self.result = response.data
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
selectPage: function (page) {
|
||||
this.page = page
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return {
|
||||
searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, name, account…')
|
||||
}
|
||||
},
|
||||
actionFilters () {
|
||||
var currentFilters = {
|
||||
q: this.search.query
|
||||
}
|
||||
if (this.filters) {
|
||||
return _.merge(currentFilters, this.filters)
|
||||
} else {
|
||||
return currentFilters
|
||||
}
|
||||
},
|
||||
actions () {
|
||||
// let deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete')
|
||||
// let confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. This action is irreversible.')
|
||||
return [
|
||||
// {
|
||||
// name: 'delete',
|
||||
// label: deleteLabel,
|
||||
// confirmationMessage: confirmationMessage,
|
||||
// isDangerous: true,
|
||||
// allowAll: false,
|
||||
// confirmColor: 'red',
|
||||
// },
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
search (newValue) {
|
||||
this.page = 1
|
||||
this.fetchData()
|
||||
},
|
||||
page () {
|
||||
this.fetchData()
|
||||
},
|
||||
ordering () {
|
||||
this.fetchData()
|
||||
},
|
||||
orderingDirection () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -8,6 +8,15 @@
|
|||
<input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
|
||||
</form>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><translate translate-context="*/*/*">Category</translate></label>
|
||||
<select class="ui dropdown" @change="addSearchToken('category', $event.target.value)" :value="getTokenValue('category', '')">
|
||||
<option value=""><translate translate-context="Content/*/Dropdown">All</translate></option>
|
||||
<option value="podcast">{{ sharedLabels.fields.content_category.choices.podcast }}</option>
|
||||
<option value="music">{{ sharedLabels.fields.content_category.choices.music }}</option>
|
||||
<option value="other">{{ sharedLabels.fields.content_category.choices.other }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
|
||||
<select class="ui dropdown" v-model="ordering">
|
||||
|
@ -45,7 +54,9 @@
|
|||
</template>
|
||||
<template slot="row-cells" slot-scope="scope">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.id }}">{{ scope.obj.name }}</router-link>
|
||||
<router-link :to="getUrl(scope.obj)">
|
||||
{{ scope.obj.name }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="!scope.obj.is_local">
|
||||
|
@ -60,10 +71,10 @@
|
|||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ scope.obj.albums.length }}
|
||||
{{ scope.obj.albums_count }}
|
||||
</td>
|
||||
<td>
|
||||
{{ scope.obj.tracks.length }}
|
||||
{{ scope.obj.tracks_count }}
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="scope.obj.creation_date"></human-date>
|
||||
|
@ -136,6 +147,12 @@ export default {
|
|||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
getUrl (artist) {
|
||||
if (artist.channel) {
|
||||
return {name: 'manage.channels.detail', params: {id: artist.channel }}
|
||||
}
|
||||
return {name: 'manage.library.artists.detail', params: {id: artist.id }}
|
||||
},
|
||||
fetchData () {
|
||||
let params = _.merge({
|
||||
'page': this.page,
|
||||
|
|
|
@ -49,7 +49,20 @@ export default {
|
|||
artist = album.artist
|
||||
}
|
||||
}
|
||||
if (artist) {
|
||||
|
||||
if (channel) {
|
||||
reportableObjs.push({
|
||||
label: this.$pgettext('*/Moderation/*/Verb', "Report this channel…"),
|
||||
target: {
|
||||
type: 'channel',
|
||||
uuid: channel.uuid,
|
||||
label: channel.artist.name,
|
||||
_obj: channel,
|
||||
typeLabel: this.$pgettext("*/*/*", 'Channel'),
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (artist) {
|
||||
reportableObjs.push({
|
||||
label: this.$pgettext('*/Moderation/*/Verb', "Report this artist…"),
|
||||
target: {
|
||||
|
|
|
@ -56,6 +56,14 @@ export default {
|
|||
summary: {
|
||||
label: this.$pgettext('Content/Account/*', 'Bio'),
|
||||
},
|
||||
content_category: {
|
||||
label: this.$pgettext('Content/*/Dropdown.Label/Noun', 'Content category'),
|
||||
choices: {
|
||||
podcast: this.$pgettext('Content/*/Dropdown', 'Podcast'),
|
||||
music: this.$pgettext('*/*/*', 'Music'),
|
||||
other: this.$pgettext('*/*/*', 'Other'),
|
||||
},
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
||||
|
|
|
@ -140,6 +140,9 @@ export default {
|
|||
return
|
||||
}
|
||||
let fid = this.target._obj.fid
|
||||
if (this.target.type === 'channel' && this.target._obj.actor ) {
|
||||
fid = this.target._obj.actor.fid
|
||||
}
|
||||
if (!fid) {
|
||||
return this.$store.getters['instance/domain']
|
||||
}
|
||||
|
|
|
@ -179,6 +179,7 @@ export default {
|
|||
label: this.$pgettext('*/*/*/Noun', 'Account'),
|
||||
icon: 'user',
|
||||
urls: {
|
||||
getDetail: (obj) => { return {name: 'profile.full.overview', params: {username: obj.preferred_username, domain: obj.domain}}},
|
||||
getAdminDetail: (obj) => { return {name: 'manage.moderation.accounts.detail', params: {id: `${obj.preferred_username}@${obj.domain}`}}}
|
||||
},
|
||||
moderatedFields: [
|
||||
|
@ -194,6 +195,33 @@ export default {
|
|||
},
|
||||
]
|
||||
},
|
||||
channel: {
|
||||
label: this.$pgettext('*/*/*', 'Channel'),
|
||||
icon: 'stream',
|
||||
urls: {
|
||||
getDetail: (obj) => { return {name: 'channels.detail', params: {id: obj.uuid}}},
|
||||
getAdminDetail: (obj) => { return {name: 'manage.channels.detail', params: {id: obj.uuid}}}
|
||||
},
|
||||
moderatedFields: [
|
||||
{
|
||||
id: 'name',
|
||||
label: this.$pgettext('*/*/*/Noun', 'Name'),
|
||||
getValue: (obj) => { return obj.name }
|
||||
},
|
||||
{
|
||||
id: 'creation_date',
|
||||
label: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
||||
getValue: (obj) => { return obj.creation_date }
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
type: 'tags',
|
||||
label: this.$pgettext('*/*/*/Noun', 'Tags'),
|
||||
getValue: (obj) => { return obj.tags },
|
||||
getValueRepr: getTagsValueRepr
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -298,6 +298,28 @@ export default new Router({
|
|||
},
|
||||
{
|
||||
path: "channels",
|
||||
name: "manage.channels",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "admin" */ "@/views/admin/ChannelsList"
|
||||
),
|
||||
props: route => {
|
||||
return {
|
||||
defaultQuery: route.query.q
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "channels/:id",
|
||||
name: "manage.channels.detail",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "admin" */ "@/views/admin/ChannelDetail"
|
||||
),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: "albums",
|
||||
name: "manage.library.albums",
|
||||
component: () =>
|
||||
import(
|
||||
|
|
|
@ -0,0 +1,369 @@
|
|||
<template>
|
||||
<main>
|
||||
<div v-if="isLoading" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="object">
|
||||
<section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.artist.name">
|
||||
<div class="ui stackable one column grid">
|
||||
<div class="ui column">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui header">
|
||||
<img v-if="object.artist.cover && object.artist.cover.square_crop" v-lazy="$store.getters['instance/absoluteUrl'](object.artist.cover.square_crop)">
|
||||
<img v-else src="../../assets/audio/default-cover.png">
|
||||
<div class="content">
|
||||
{{ object.artist.name | truncate(100) }}
|
||||
<div class="sub header">
|
||||
<template v-if="object.artist.is_local">
|
||||
<span class="ui tiny teal label">
|
||||
<i class="home icon"></i>
|
||||
<translate translate-context="Content/Moderation/*/Short, Noun">Local</translate>
|
||||
</span>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<template v-if="object.artist.tags && object.artist.tags.length > 0">
|
||||
<tags-list :limit="5" detail-route="manage.library.tags.detail" :tags="object.artist.tags"></tags-list>
|
||||
<div class="ui hidden divider"></div>
|
||||
</template>
|
||||
|
||||
<div class="header-buttons">
|
||||
|
||||
<div class="ui icon buttons">
|
||||
<router-link class="ui labeled icon button" :to="{name: 'channels.detail', params: {id: object.uuid }}">
|
||||
<i class="info icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link/Verb">Open local profile</translate>
|
||||
</router-link>
|
||||
<div class="ui floating dropdown icon button" v-dropdown>
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="menu">
|
||||
<a
|
||||
v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
|
||||
class="basic item"
|
||||
:href="$store.getters['instance/absoluteUrl'](`/api/admin/audio/channel/${object.id}`)"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<i class="wrench icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>
|
||||
</a>
|
||||
<fetch-button @refresh="fetchData" v-if="!object.actor.is_local" class="basic item" :url="`channels/${object.uuid}/fetches/`">
|
||||
<i class="refresh icon"></i>
|
||||
<translate translate-context="Content/Moderation/Button/Verb">Refresh from remote server</translate>
|
||||
</fetch-button>
|
||||
<a class="basic item" :href="object.actor.url || object.actor.fid" target="_blank" rel="noopener noreferrer">
|
||||
<i class="external icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui buttons">
|
||||
<dangerous-button
|
||||
:class="['ui', {loading: isLoading}, 'basic red button']"
|
||||
:action="remove">
|
||||
<translate translate-context="*/*/*/Verb">Delete</translate>
|
||||
<p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this channel?</translate></p>
|
||||
<div slot="modal-content">
|
||||
<p><translate translate-context="Content/Moderation/Paragraph">The channel will be removed, as well as associated uploads, tracks, and albums. This action is irreversible.</translate></p>
|
||||
</div>
|
||||
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
|
||||
</dangerous-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui stackable three column grid">
|
||||
<div class="column">
|
||||
<section>
|
||||
<h3 class="ui header">
|
||||
<i class="info icon"></i>
|
||||
<div class="content">
|
||||
<translate translate-context="Content/Moderation/Title">Channel data</translate>
|
||||
</div>
|
||||
</h3>
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="*/*/*/Noun">Name</translate>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.artist.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.channels', query: {q: getQuery('category', object.artist.content_category) }}">
|
||||
<translate translate-context="*/*/*">Category</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.artist.content_category }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: object.attributed_to.full_username }}">
|
||||
<translate translate-context="*/*/*/Noun">Account</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.attributed_to.preferred_username }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!object.actor.is_local">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.actor.domain }}">
|
||||
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.actor.domain }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="object.artist.description">
|
||||
<td>
|
||||
<translate translate-context="'*/*/*/Noun">Description</translate>
|
||||
</td>
|
||||
<td v-html="object.artist.description.html"></td>
|
||||
</tr>
|
||||
<tr v-if="object.actor.url">
|
||||
<td>
|
||||
<translate translate-context="'Content/*/*/Noun">URL</translate>
|
||||
</td>
|
||||
<td>
|
||||
<a :href="object.actor.url" rel="noreferrer noopener" target="_blank">{{ object.actor.url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="object.rss_url">
|
||||
<td>
|
||||
<translate translate-context="'*/*/*">RSS Feed</translate>
|
||||
</td>
|
||||
<td>
|
||||
<a :href="object.rss_url" rel="noreferrer noopener" target="_blank">{{ object.rss_url }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
<div class="column">
|
||||
<section>
|
||||
<h3 class="ui header">
|
||||
<i class="feed icon"></i>
|
||||
<div class="content">
|
||||
<translate translate-context="Content/Moderation/Title">Activity</translate>
|
||||
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
|
||||
|
||||
</div>
|
||||
</h3>
|
||||
<div v-if="isLoadingStats" class="ui placeholder">
|
||||
<div class="full line"></div>
|
||||
<div class="short line"></div>
|
||||
<div class="medium line"></div>
|
||||
<div class="long line"></div>
|
||||
</div>
|
||||
<table v-else class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate>
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="object.creation_date"></human-date>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="*/*/*/Noun">Listenings</translate>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.listenings }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="*/*/*">Favorited tracks</translate>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.track_favorites }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="*/*/*">Playlists</translate>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.playlists }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `channel:${object.uuid}`) }}">
|
||||
<translate translate-context="Content/Moderation/Table.Label/Noun">Linked reports</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.reports }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.edits', query: {q: getQuery('target', 'artist ' + object.artist.id)}}">
|
||||
<translate translate-context="*/Admin/*/Noun">Edits</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.mutations }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
<div class="column">
|
||||
<section>
|
||||
<h3 class="ui header">
|
||||
<i class="music icon"></i>
|
||||
<div class="content">
|
||||
<translate translate-context="Content/Moderation/Title">Audio content</translate>
|
||||
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
|
||||
|
||||
</div>
|
||||
</h3>
|
||||
<div v-if="isLoadingStats" class="ui placeholder">
|
||||
<div class="full line"></div>
|
||||
<div class="short line"></div>
|
||||
<div class="medium line"></div>
|
||||
<div class="long line"></div>
|
||||
</div>
|
||||
<table v-else class="ui very basic table">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="Content/Moderation/Table.Label/Noun">Cached size</translate>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.media_downloaded_size | humanSize }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="Content/Moderation/Table.Label">Total size</translate>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.media_total_size | humanSize }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('channel_id', object.uuid) }}">
|
||||
<translate translate-context="*/*/*">Uploads</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.uploads }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.albums', query: {q: getQuery('channel_id', object.uuid) }}">
|
||||
<translate translate-context="*/*/*">Albums</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.artist.albums_count }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('channel_id', object.uuid) }}">
|
||||
<translate translate-context="*/*/*">Tracks</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.artist.tracks_count }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios"
|
||||
import logger from "@/logging"
|
||||
|
||||
import TagsList from "@/components/tags/List"
|
||||
import FetchButton from "@/components/federation/FetchButton"
|
||||
|
||||
export default {
|
||||
props: ["id"],
|
||||
components: {
|
||||
FetchButton,
|
||||
TagsList
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: true,
|
||||
isLoadingStats: false,
|
||||
object: null,
|
||||
stats: null,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData()
|
||||
this.fetchStats()
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = `manage/channels/${this.id}/`
|
||||
axios.get(url).then(response => {
|
||||
self.object = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
fetchStats() {
|
||||
var self = this
|
||||
this.isLoadingStats = true
|
||||
let url = `manage/channels/${this.id}/stats/`
|
||||
axios.get(url).then(response => {
|
||||
self.stats = response.data
|
||||
self.isLoadingStats = false
|
||||
})
|
||||
},
|
||||
remove () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = `manage/channels/${this.id}/`
|
||||
axios.delete(url).then(response => {
|
||||
self.$router.push({name: 'manage.channels'})
|
||||
})
|
||||
},
|
||||
getQuery (field, value) {
|
||||
return `${field}:"${value}"`
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels() {
|
||||
return {
|
||||
statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object'),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<main v-title="labels.title">
|
||||
<section class="ui vertical stripe segment">
|
||||
<h2 class="ui header">{{ labels.title }}</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
<channels-table :update-url="true" :default-query="defaultQuery"></channels-table>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChannelsTable from "@/components/manage/ChannelsTable"
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChannelsTable
|
||||
},
|
||||
props: {
|
||||
defaultQuery: {type: String, required: false},
|
||||
},
|
||||
computed: {
|
||||
labels() {
|
||||
return {
|
||||
title: this.$pgettext('*/*/*', 'Channels')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -108,6 +108,16 @@
|
|||
{{ object.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.artists', query: {q: getQuery('category', object.content_category) }}">
|
||||
<translate translate-context="*/*/*">Category</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.content_category }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!object.is_local">
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
|
||||
|
@ -265,7 +275,7 @@
|
|||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.albums.length }}
|
||||
{{ object.albums_count }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -275,7 +285,7 @@
|
|||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ object.tracks.length }}
|
||||
{{ object.tracks_count }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -321,8 +331,12 @@ export default {
|
|||
this.isLoading = true
|
||||
let url = `manage/library/artists/${this.id}/`
|
||||
axios.get(url).then(response => {
|
||||
self.object = response.data
|
||||
self.isLoading = false
|
||||
if (response.data.channel) {
|
||||
self.$router.push({name: "manage.channels.detail", params: {id: response.data.channel}})
|
||||
} else {
|
||||
self.object = response.data
|
||||
self.isLoading = false
|
||||
}
|
||||
})
|
||||
},
|
||||
fetchStats() {
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'manage.library.edits'}"><translate translate-context="*/Admin/*/Noun">Edits</translate></router-link>
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'manage.channels'}"><translate translate-context="*/*/*">Channels</translate></router-link>
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'manage.library.artists'}"><translate translate-context="*/*/*/Noun">Artists</translate></router-link>
|
||||
|
|
|
@ -343,7 +343,16 @@
|
|||
{{ stats.media_total_size | humanSize }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.channels', query: {q: getQuery('account', object.full_username) }}">
|
||||
<translate translate-context="*/*/*">Channels</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.channels }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('account', object.full_username) }}">
|
||||
|
|
|
@ -266,6 +266,16 @@
|
|||
{{ stats.media_total_size | humanSize }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.channels', query: {q: getQuery('domain', object.name) }}">
|
||||
<translate translate-context="*/*/*">Channels</translate>
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
{{ stats.channels }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('domain', object.name) }}">
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
<div
|
||||
role="button"
|
||||
class="basic item"
|
||||
v-for="obj in getReportableObjs({channel: object})"
|
||||
v-for="obj in getReportableObjs({account: object.attributed_to, channel: object})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
|
@ -112,7 +112,7 @@
|
|||
</template>
|
||||
<template v-if="$store.state.auth.availablePermissions['library']" >
|
||||
<div class="divider"></div>
|
||||
<router-link class="basic item" :to="{name: 'manage.library.channels.detail', params: {id: object.uuid}}">
|
||||
<router-link class="basic item" :to="{name: 'manage.channels.detail', params: {id: object.uuid}}">
|
||||
<i class="wrench icon"></i>
|
||||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||
</router-link>
|
||||
|
|
Loading…
Reference in New Issue