Merge branch '170-admin-ui' into 'develop'
See #170: admin UI for channels, reporting channels See merge request funkwhale/funkwhale!1072
This commit is contained in:
commit
0eeead34a4
|
@ -69,6 +69,15 @@ class Channel(models.Model):
|
||||||
|
|
||||||
objects = ChannelQuerySet.as_manager()
|
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):
|
def get_absolute_url(self):
|
||||||
suffix = self.uuid
|
suffix = self.uuid
|
||||||
if self.actor.is_local:
|
if self.actor.is_local:
|
||||||
|
@ -78,9 +87,7 @@ class Channel(models.Model):
|
||||||
return federation_utils.full_url("/channels/{}".format(suffix))
|
return federation_utils.full_url("/channels/{}".format(suffix))
|
||||||
|
|
||||||
def get_rss_url(self):
|
def get_rss_url(self):
|
||||||
if not self.artist.is_local or self.actor.preferred_username.startswith(
|
if not self.artist.is_local or self.is_external_rss:
|
||||||
"rssfeed-"
|
|
||||||
):
|
|
||||||
return self.rss_url
|
return self.rss_url
|
||||||
|
|
||||||
return federation_utils.full_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):
|
def generate_actor(username, **kwargs):
|
||||||
actor_data = user_models.get_actor_data(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),
|
actors=models.Count("actors", distinct=True),
|
||||||
outbox_activities=models.Count("actors__outbox_activities", distinct=True),
|
outbox_activities=models.Count("actors__outbox_activities", distinct=True),
|
||||||
libraries=models.Count("actors__libraries", distinct=True),
|
libraries=models.Count("actors__libraries", distinct=True),
|
||||||
|
channels=models.Count("actors__owned_channels", distinct=True),
|
||||||
received_library_follows=models.Count(
|
received_library_follows=models.Count(
|
||||||
"actors__libraries__received_follows", distinct=True
|
"actors__libraries__received_follows", distinct=True
|
||||||
),
|
),
|
||||||
|
@ -283,6 +284,7 @@ class Actor(models.Model):
|
||||||
data = Actor.objects.filter(pk=self.pk).aggregate(
|
data = Actor.objects.filter(pk=self.pk).aggregate(
|
||||||
outbox_activities=models.Count("outbox_activities", distinct=True),
|
outbox_activities=models.Count("outbox_activities", distinct=True),
|
||||||
libraries=models.Count("libraries", distinct=True),
|
libraries=models.Count("libraries", distinct=True),
|
||||||
|
channels=models.Count("owned_channels", distinct=True),
|
||||||
received_library_follows=models.Count(
|
received_library_follows=models.Count(
|
||||||
"libraries__received_follows", distinct=True
|
"libraries__received_follows", distinct=True
|
||||||
),
|
),
|
||||||
|
|
|
@ -482,6 +482,8 @@ def inbox_flag(payload, context):
|
||||||
@outbox.register({"type": "Flag"})
|
@outbox.register({"type": "Flag"})
|
||||||
def outbox_flag(context):
|
def outbox_flag(context):
|
||||||
report = context["report"]
|
report = context["report"]
|
||||||
|
if not report.target or not report.target.fid:
|
||||||
|
return
|
||||||
actor = actors.get_service_actor()
|
actor = actors.get_service_actor()
|
||||||
serializer = serializers.FlagSerializer(report)
|
serializer = serializers.FlagSerializer(report)
|
||||||
yield {
|
yield {
|
||||||
|
|
|
@ -266,5 +266,11 @@ def get_object_by_fid(fid, local=None):
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
raise ObjectDoesNotExist()
|
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 filters as common_filters
|
||||||
from funkwhale_api.common import search
|
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 models as federation_models
|
||||||
from funkwhale_api.federation import utils as federation_utils
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
from funkwhale_api.moderation import models as moderation_models
|
from funkwhale_api.moderation import models as moderation_models
|
||||||
|
@ -34,6 +35,34 @@ def get_actor_filter(actor_field):
|
||||||
return {"field": ActorField(), "handler": handler}
|
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):
|
class ManageArtistFilterSet(filters.FilterSet):
|
||||||
q = fields.SmartSearchFilter(
|
q = fields.SmartSearchFilter(
|
||||||
config=search.SearchConfig(
|
config=search.SearchConfig(
|
||||||
|
@ -52,6 +81,7 @@ class ManageArtistFilterSet(filters.FilterSet):
|
||||||
"field": forms.IntegerField(),
|
"field": forms.IntegerField(),
|
||||||
"distinct": True,
|
"distinct": True,
|
||||||
},
|
},
|
||||||
|
"category": {"to": "content_category"},
|
||||||
"tag": {"to": "tagged_items__tag__name", "distinct": True},
|
"tag": {"to": "tagged_items__tag__name", "distinct": True},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -59,7 +89,7 @@ class ManageArtistFilterSet(filters.FilterSet):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.Artist
|
model = music_models.Artist
|
||||||
fields = ["q", "name", "mbid", "fid"]
|
fields = ["q", "name", "mbid", "fid", "content_category"]
|
||||||
|
|
||||||
|
|
||||||
class ManageAlbumFilterSet(filters.FilterSet):
|
class ManageAlbumFilterSet(filters.FilterSet):
|
||||||
|
|
|
@ -3,6 +3,7 @@ from django.db import transaction
|
||||||
|
|
||||||
from rest_framework import serializers
|
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 fields as common_fields
|
||||||
from funkwhale_api.common import serializers as common_serializers
|
from funkwhale_api.common import serializers as common_serializers
|
||||||
from funkwhale_api.common import utils as common_utils
|
from funkwhale_api.common import utils as common_utils
|
||||||
|
@ -386,26 +387,39 @@ class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer):
|
||||||
class ManageArtistSerializer(
|
class ManageArtistSerializer(
|
||||||
music_serializers.OptionalDescriptionMixin, ManageBaseArtistSerializer
|
music_serializers.OptionalDescriptionMixin, ManageBaseArtistSerializer
|
||||||
):
|
):
|
||||||
albums = ManageNestedAlbumSerializer(many=True)
|
|
||||||
tracks = ManageNestedTrackSerializer(many=True)
|
|
||||||
attributed_to = ManageBaseActorSerializer()
|
attributed_to = ManageBaseActorSerializer()
|
||||||
tags = serializers.SerializerMethodField()
|
tags = serializers.SerializerMethodField()
|
||||||
|
tracks_count = serializers.SerializerMethodField()
|
||||||
|
albums_count = serializers.SerializerMethodField()
|
||||||
|
channel = serializers.SerializerMethodField()
|
||||||
cover = music_serializers.cover_field
|
cover = music_serializers.cover_field
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.Artist
|
model = music_models.Artist
|
||||||
fields = ManageBaseArtistSerializer.Meta.fields + [
|
fields = ManageBaseArtistSerializer.Meta.fields + [
|
||||||
"albums",
|
"tracks_count",
|
||||||
"tracks",
|
"albums_count",
|
||||||
"attributed_to",
|
"attributed_to",
|
||||||
"tags",
|
"tags",
|
||||||
"cover",
|
"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):
|
def get_tags(self, obj):
|
||||||
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
|
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
|
||||||
return [ti.tag.name for ti in 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):
|
class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
|
||||||
pass
|
pass
|
||||||
|
@ -743,3 +757,23 @@ class ManageUserRequestSerializer(serializers.ModelSerializer):
|
||||||
def get_notes(self, o):
|
def get_notes(self, o):
|
||||||
notes = getattr(o, "_prefetched_notes", [])
|
notes = getattr(o, "_prefetched_notes", [])
|
||||||
return ManageBaseNoteSerializer(notes, many=True).data
|
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 = routers.OptionalSlashRouter()
|
||||||
other_router.register(r"accounts", views.ManageActorViewSet, "accounts")
|
other_router.register(r"accounts", views.ManageActorViewSet, "accounts")
|
||||||
|
other_router.register(r"channels", views.ManageChannelViewSet, "channels")
|
||||||
other_router.register(r"tags", views.ManageTagViewSet, "tags")
|
other_router.register(r"tags", views.ManageTagViewSet, "tags")
|
||||||
|
|
||||||
urlpatterns = [
|
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.db.models.functions import Coalesce, Length
|
||||||
from django.shortcuts import get_object_or_404
|
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 models as common_models
|
||||||
from funkwhale_api.common import preferences, decorators
|
from funkwhale_api.common import preferences, decorators
|
||||||
from funkwhale_api.common import utils as common_utils
|
from funkwhale_api.common import utils as common_utils
|
||||||
from funkwhale_api.favorites import models as favorites_models
|
from funkwhale_api.favorites import models as favorites_models
|
||||||
from funkwhale_api.federation import models as federation_models
|
from funkwhale_api.federation import models as federation_models
|
||||||
from funkwhale_api.federation import tasks as federation_tasks
|
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.history import models as history_models
|
||||||
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
|
||||||
|
@ -25,37 +28,39 @@ from funkwhale_api.users import models as users_models
|
||||||
from . import filters, serializers
|
from . import filters, serializers
|
||||||
|
|
||||||
|
|
||||||
def get_stats(tracks, target):
|
def get_stats(tracks, target, ignore_fields=[]):
|
||||||
data = {}
|
|
||||||
tracks = list(tracks.values_list("pk", flat=True))
|
tracks = list(tracks.values_list("pk", flat=True))
|
||||||
uploads = music_models.Upload.objects.filter(track__in=tracks)
|
uploads = music_models.Upload.objects.filter(track__in=tracks)
|
||||||
data["listenings"] = history_models.Listening.objects.filter(
|
fields = {
|
||||||
track__in=tracks
|
"listenings": history_models.Listening.objects.filter(track__in=tracks),
|
||||||
).count()
|
"mutations": common_models.Mutation.objects.get_for_target(target),
|
||||||
data["mutations"] = common_models.Mutation.objects.get_for_target(target).count()
|
"playlists": (
|
||||||
data["playlists"] = (
|
playlists_models.PlaylistTrack.objects.filter(track__in=tracks)
|
||||||
playlists_models.PlaylistTrack.objects.filter(track__in=tracks)
|
.values_list("playlist", flat=True)
|
||||||
.values_list("playlist", flat=True)
|
.distinct()
|
||||||
.distinct()
|
),
|
||||||
.count()
|
"track_favorites": (
|
||||||
)
|
favorites_models.TrackFavorite.objects.filter(track__in=tracks)
|
||||||
data["track_favorites"] = favorites_models.TrackFavorite.objects.filter(
|
),
|
||||||
track__in=tracks
|
"libraries": (
|
||||||
).count()
|
uploads.filter(library__channel=None)
|
||||||
data["libraries"] = (
|
.values_list("library", flat=True)
|
||||||
uploads.filter(library__channel=None)
|
.distinct()
|
||||||
.values_list("library", flat=True)
|
),
|
||||||
.distinct()
|
"channels": (
|
||||||
.count()
|
uploads.exclude(library__channel=None)
|
||||||
)
|
.values_list("library", flat=True)
|
||||||
data["channels"] = (
|
.distinct()
|
||||||
uploads.exclude(library__channel=None)
|
),
|
||||||
.values_list("library", flat=True)
|
"uploads": uploads,
|
||||||
.distinct()
|
"reports": moderation_models.Report.objects.get_for_target(target),
|
||||||
.count()
|
}
|
||||||
)
|
data = {}
|
||||||
data["uploads"] = uploads.count()
|
for key, qs in fields.items():
|
||||||
data["reports"] = moderation_models.Report.objects.get_for_target(target).count()
|
if key in ignore_fields:
|
||||||
|
continue
|
||||||
|
data[key] = qs.count()
|
||||||
|
|
||||||
data.update(get_media_stats(uploads))
|
data.update(get_media_stats(uploads))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -78,17 +83,10 @@ class ManageArtistViewSet(
|
||||||
queryset = (
|
queryset = (
|
||||||
music_models.Artist.objects.all()
|
music_models.Artist.objects.all()
|
||||||
.order_by("-id")
|
.order_by("-id")
|
||||||
.select_related("attributed_to", "attachment_cover",)
|
.select_related("attributed_to", "attachment_cover", "channel")
|
||||||
.prefetch_related(
|
.annotate(_tracks_count=Count("tracks"))
|
||||||
"tracks",
|
.annotate(_albums_count=Count("albums"))
|
||||||
Prefetch(
|
.prefetch_related(music_views.TAG_PREFETCH)
|
||||||
"albums",
|
|
||||||
queryset=music_models.Album.objects.select_related(
|
|
||||||
"attachment_cover"
|
|
||||||
).annotate(tracks_count=Count("tracks")),
|
|
||||||
),
|
|
||||||
music_views.TAG_PREFETCH,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
serializer_class = serializers.ManageArtistSerializer
|
serializer_class = serializers.ManageArtistSerializer
|
||||||
filterset_class = filters.ManageArtistFilterSet
|
filterset_class = filters.ManageArtistFilterSet
|
||||||
|
@ -661,3 +659,64 @@ class ManageUserRequestViewSet(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
serializer.save()
|
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
|
import persisting_theory
|
||||||
from rest_framework import serializers
|
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 fields as common_fields
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
from funkwhale_api.federation import models as federation_models
|
from funkwhale_api.federation import models as federation_models
|
||||||
|
@ -61,20 +62,36 @@ class UserFilterSerializer(serializers.ModelSerializer):
|
||||||
state_serializers = persisting_theory.Registry()
|
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")
|
TAGS_FIELD = serializers.ListField(source="get_tags")
|
||||||
|
|
||||||
|
|
||||||
@state_serializers.register(name="music.Artist")
|
@state_serializers.register(name="music.Artist")
|
||||||
class ArtistStateSerializer(serializers.ModelSerializer):
|
class ArtistStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
|
||||||
tags = TAGS_FIELD
|
tags = TAGS_FIELD
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.Artist
|
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")
|
@state_serializers.register(name="music.Album")
|
||||||
class AlbumStateSerializer(serializers.ModelSerializer):
|
class AlbumStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
|
||||||
tags = TAGS_FIELD
|
tags = TAGS_FIELD
|
||||||
artist = ArtistStateSerializer()
|
artist = ArtistStateSerializer()
|
||||||
|
|
||||||
|
@ -90,11 +107,12 @@ class AlbumStateSerializer(serializers.ModelSerializer):
|
||||||
"artist",
|
"artist",
|
||||||
"release_date",
|
"release_date",
|
||||||
"tags",
|
"tags",
|
||||||
|
"description",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@state_serializers.register(name="music.Track")
|
@state_serializers.register(name="music.Track")
|
||||||
class TrackStateSerializer(serializers.ModelSerializer):
|
class TrackStateSerializer(DescriptionStateMixin, serializers.ModelSerializer):
|
||||||
tags = TAGS_FIELD
|
tags = TAGS_FIELD
|
||||||
artist = ArtistStateSerializer()
|
artist = ArtistStateSerializer()
|
||||||
album = AlbumStateSerializer()
|
album = AlbumStateSerializer()
|
||||||
|
@ -115,6 +133,7 @@ class TrackStateSerializer(serializers.ModelSerializer):
|
||||||
"license",
|
"license",
|
||||||
"copyright",
|
"copyright",
|
||||||
"tags",
|
"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):
|
def get_actor_query(attr, value):
|
||||||
data = federation_utils.get_actor_data_from_username(value)
|
data = federation_utils.get_actor_data_from_username(value)
|
||||||
return federation_utils.get_actor_from_username_data_query(None, data)
|
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):
|
def get_target_owner(target):
|
||||||
mapping = {
|
mapping = {
|
||||||
|
audio_models.Channel: lambda t: t.attributed_to,
|
||||||
music_models.Artist: lambda t: t.attributed_to,
|
music_models.Artist: lambda t: t.attributed_to,
|
||||||
music_models.Album: lambda t: t.attributed_to,
|
music_models.Album: lambda t: t.attributed_to,
|
||||||
music_models.Track: lambda t: t.attributed_to,
|
music_models.Track: lambda t: t.attributed_to,
|
||||||
|
@ -175,6 +225,11 @@ def get_target_owner(target):
|
||||||
|
|
||||||
|
|
||||||
TARGET_CONFIG = {
|
TARGET_CONFIG = {
|
||||||
|
"channel": {
|
||||||
|
"queryset": audio_models.Channel.objects.all(),
|
||||||
|
"id_attr": "uuid",
|
||||||
|
"id_field": serializers.UUIDField(),
|
||||||
|
},
|
||||||
"artist": {"queryset": music_models.Artist.objects.all()},
|
"artist": {"queryset": music_models.Artist.objects.all()},
|
||||||
"album": {"queryset": music_models.Album.objects.all()},
|
"album": {"queryset": music_models.Album.objects.all()},
|
||||||
"track": {"queryset": music_models.Track.objects.all()},
|
"track": {"queryset": music_models.Track.objects.all()},
|
||||||
|
|
|
@ -126,6 +126,7 @@ def test_domain_stats(factories):
|
||||||
"libraries": 0,
|
"libraries": 0,
|
||||||
"tracks": 0,
|
"tracks": 0,
|
||||||
"albums": 0,
|
"albums": 0,
|
||||||
|
"channels": 0,
|
||||||
"uploads": 0,
|
"uploads": 0,
|
||||||
"artists": 0,
|
"artists": 0,
|
||||||
"outbox_activities": 0,
|
"outbox_activities": 0,
|
||||||
|
@ -148,6 +149,7 @@ def test_actor_stats(factories):
|
||||||
"uploads": 0,
|
"uploads": 0,
|
||||||
"artists": 0,
|
"artists": 0,
|
||||||
"reports": 0,
|
"reports": 0,
|
||||||
|
"channels": 0,
|
||||||
"requests": 0,
|
"requests": 0,
|
||||||
"outbox_activities": 0,
|
"outbox_activities": 0,
|
||||||
"received_library_follows": 0,
|
"received_library_follows": 0,
|
||||||
|
|
|
@ -844,6 +844,7 @@ def test_inbox_delete_actor_doesnt_delete_local_actor(factories):
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"factory_name, factory_kwargs",
|
"factory_name, factory_kwargs",
|
||||||
[
|
[
|
||||||
|
("audio.Channel", {"local": True}),
|
||||||
("federation.Actor", {"local": True}),
|
("federation.Actor", {"local": True}),
|
||||||
("music.Artist", {"local": True}),
|
("music.Artist", {"local": True}),
|
||||||
("music.Album", {"local": True}),
|
("music.Album", {"local": True}),
|
||||||
|
@ -885,6 +886,7 @@ def test_inbox_flag(factory_name, factory_kwargs, factories, mocker):
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"factory_name, factory_kwargs",
|
"factory_name, factory_kwargs",
|
||||||
[
|
[
|
||||||
|
("audio.Channel", {"local": True}),
|
||||||
("federation.Actor", {"local": True}),
|
("federation.Actor", {"local": True}),
|
||||||
("music.Artist", {"local": True}),
|
("music.Artist", {"local": True}),
|
||||||
("music.Album", {"local": True}),
|
("music.Album", {"local": True}),
|
||||||
|
|
|
@ -207,3 +207,9 @@ def test_get_obj_by_fid(factory_name, factories):
|
||||||
obj = factories[factory_name]()
|
obj = factories[factory_name]()
|
||||||
factories[factory_name]()
|
factories[factory_name]()
|
||||||
assert utils.get_object_by_fid(obj.fid) == obj
|
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):
|
def test_manage_artist_serializer(factories, now, to_api_date):
|
||||||
artist = factories["music.Artist"](attributed=True, with_cover=True)
|
artist = factories["music.Artist"](attributed=True, with_cover=True)
|
||||||
track = factories["music.Track"](artist=artist)
|
channel = factories["audio.Channel"](artist=artist)
|
||||||
album = factories["music.Album"](artist=artist)
|
# put channel in cache
|
||||||
|
artist.get_channel()
|
||||||
|
setattr(artist, "_tracks_count", 12)
|
||||||
|
setattr(artist, "_albums_count", 13)
|
||||||
expected = {
|
expected = {
|
||||||
"id": artist.id,
|
"id": artist.id,
|
||||||
"domain": artist.domain_name,
|
"domain": artist.domain_name,
|
||||||
|
@ -297,12 +300,14 @@ def test_manage_artist_serializer(factories, now, to_api_date):
|
||||||
"name": artist.name,
|
"name": artist.name,
|
||||||
"mbid": artist.mbid,
|
"mbid": artist.mbid,
|
||||||
"creation_date": to_api_date(artist.creation_date),
|
"creation_date": to_api_date(artist.creation_date),
|
||||||
"albums": [serializers.ManageNestedAlbumSerializer(album).data],
|
"tracks_count": 12,
|
||||||
"tracks": [serializers.ManageNestedTrackSerializer(track).data],
|
"albums_count": 13,
|
||||||
"attributed_to": serializers.ManageBaseActorSerializer(
|
"attributed_to": serializers.ManageBaseActorSerializer(
|
||||||
artist.attributed_to
|
artist.attributed_to
|
||||||
).data,
|
).data,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
"channel": str(channel.uuid),
|
||||||
|
"content_category": artist.content_category,
|
||||||
"cover": common_serializers.AttachmentSerializer(artist.attachment_cover).data,
|
"cover": common_serializers.AttachmentSerializer(artist.attachment_cover).data,
|
||||||
}
|
}
|
||||||
s = serializers.ManageArtistSerializer(artist)
|
s = serializers.ManageArtistSerializer(artist)
|
||||||
|
@ -585,3 +590,22 @@ def test_manage_user_request_serializer(factories, to_api_date):
|
||||||
s = serializers.ManageUserRequestSerializer(user_request)
|
s = serializers.ManageUserRequestSerializer(user_request)
|
||||||
|
|
||||||
assert s.data == expected
|
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",
|
new_status="refused",
|
||||||
old_status="pending",
|
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",
|
"full_username",
|
||||||
serializers.ActorStateSerializer,
|
serializers.ActorStateSerializer,
|
||||||
),
|
),
|
||||||
|
("audio.Channel", "channel", "uuid", serializers.ChannelStateSerializer),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_report_federated_entity_serializer_save(
|
def test_report_federated_entity_serializer_save(
|
||||||
|
@ -161,6 +162,7 @@ def test_report_serializer_save_anonymous(factories, mocker):
|
||||||
("music.Library", {}, "actor"),
|
("music.Library", {}, "actor"),
|
||||||
("playlists.Playlist", {"user__with_actor": True}, "user.actor"),
|
("playlists.Playlist", {"user__with_actor": True}, "user.actor"),
|
||||||
("federation.Actor", {}, "self"),
|
("federation.Actor", {}, "self"),
|
||||||
|
("audio.Channel", {}, "attributed_to"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_get_target_owner(factory_name, factory_kwargs, owner_field, factories):
|
def test_get_target_owner(factory_name, factory_kwargs, owner_field, factories):
|
||||||
|
|
|
@ -406,9 +406,9 @@ export default {
|
||||||
},
|
},
|
||||||
'serviceWorker.updateAvailable': {
|
'serviceWorker.updateAvailable': {
|
||||||
handler (v) {
|
handler (v) {
|
||||||
// if (!v) {
|
if (!v) {
|
||||||
// return
|
return
|
||||||
// }
|
}
|
||||||
let self = this
|
let self = this
|
||||||
this.$store.commit('ui/addMessage', {
|
this.$store.commit('ui/addMessage', {
|
||||||
content: this.$pgettext("App/Message/Paragraph", "A new version of the app is available."),
|
content: this.$pgettext("App/Message/Paragraph", "A new version of the app is available."),
|
||||||
|
|
|
@ -30,7 +30,11 @@
|
||||||
:title="updatedTitle">
|
:title="updatedTitle">
|
||||||
{{ object.artist.modification_date | fromNow }}
|
{{ object.artist.modification_date | fromNow }}
|
||||||
</time>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<i class="eye slash outline icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
:key="obj.target.type + obj.target.id"
|
||||||
class="item basic"
|
class="item basic"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||||
|
@ -69,6 +69,7 @@ export default {
|
||||||
artist: {type: Object, required: false},
|
artist: {type: Object, required: false},
|
||||||
album: {type: Object, required: false},
|
album: {type: Object, required: false},
|
||||||
library: {type: Object, required: false},
|
library: {type: Object, required: false},
|
||||||
|
channel: {type: Object, required: false},
|
||||||
isPlayable: {type: Boolean, required: false, default: null}
|
isPlayable: {type: Boolean, required: false, default: null}
|
||||||
},
|
},
|
||||||
data () {
|
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" />
|
<input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
|
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
|
||||||
<select class="ui dropdown" v-model="ordering">
|
<select class="ui dropdown" v-model="ordering">
|
||||||
|
@ -45,7 +54,9 @@
|
||||||
</template>
|
</template>
|
||||||
<template slot="row-cells" slot-scope="scope">
|
<template slot="row-cells" slot-scope="scope">
|
||||||
<td>
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<template v-if="!scope.obj.is_local">
|
<template v-if="!scope.obj.is_local">
|
||||||
|
@ -60,10 +71,10 @@
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ scope.obj.albums.length }}
|
{{ scope.obj.albums_count }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ scope.obj.tracks.length }}
|
{{ scope.obj.tracks_count }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<human-date :date="scope.obj.creation_date"></human-date>
|
<human-date :date="scope.obj.creation_date"></human-date>
|
||||||
|
@ -136,6 +147,12 @@ export default {
|
||||||
this.fetchData()
|
this.fetchData()
|
||||||
},
|
},
|
||||||
methods: {
|
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 () {
|
fetchData () {
|
||||||
let params = _.merge({
|
let params = _.merge({
|
||||||
'page': this.page,
|
'page': this.page,
|
||||||
|
|
|
@ -49,7 +49,20 @@ export default {
|
||||||
artist = album.artist
|
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({
|
reportableObjs.push({
|
||||||
label: this.$pgettext('*/Moderation/*/Verb', "Report this artist…"),
|
label: this.$pgettext('*/Moderation/*/Verb', "Report this artist…"),
|
||||||
target: {
|
target: {
|
||||||
|
|
|
@ -56,6 +56,14 @@ export default {
|
||||||
summary: {
|
summary: {
|
||||||
label: this.$pgettext('Content/Account/*', 'Bio'),
|
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: {
|
filters: {
|
||||||
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'),
|
||||||
|
|
|
@ -140,6 +140,9 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let fid = this.target._obj.fid
|
let fid = this.target._obj.fid
|
||||||
|
if (this.target.type === 'channel' && this.target._obj.actor ) {
|
||||||
|
fid = this.target._obj.actor.fid
|
||||||
|
}
|
||||||
if (!fid) {
|
if (!fid) {
|
||||||
return this.$store.getters['instance/domain']
|
return this.$store.getters['instance/domain']
|
||||||
}
|
}
|
||||||
|
|
|
@ -179,6 +179,7 @@ export default {
|
||||||
label: this.$pgettext('*/*/*/Noun', 'Account'),
|
label: this.$pgettext('*/*/*/Noun', 'Account'),
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
urls: {
|
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}`}}}
|
getAdminDetail: (obj) => { return {name: 'manage.moderation.accounts.detail', params: {id: `${obj.preferred_username}@${obj.domain}`}}}
|
||||||
},
|
},
|
||||||
moderatedFields: [
|
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",
|
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",
|
name: "manage.library.albums",
|
||||||
component: () =>
|
component: () =>
|
||||||
import(
|
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 }}
|
{{ object.name }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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">
|
<tr v-if="!object.is_local">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
|
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
|
||||||
|
@ -265,7 +275,7 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ object.albums.length }}
|
{{ object.albums_count }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -275,7 +285,7 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ object.tracks.length }}
|
{{ object.tracks_count }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -321,8 +331,12 @@ export default {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
let url = `manage/library/artists/${this.id}/`
|
let url = `manage/library/artists/${this.id}/`
|
||||||
axios.get(url).then(response => {
|
axios.get(url).then(response => {
|
||||||
self.object = response.data
|
if (response.data.channel) {
|
||||||
self.isLoading = false
|
self.$router.push({name: "manage.channels.detail", params: {id: response.data.channel}})
|
||||||
|
} else {
|
||||||
|
self.object = response.data
|
||||||
|
self.isLoading = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchStats() {
|
fetchStats() {
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
<router-link
|
<router-link
|
||||||
class="ui item"
|
class="ui item"
|
||||||
:to="{name: 'manage.library.edits'}"><translate translate-context="*/Admin/*/Noun">Edits</translate></router-link>
|
: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
|
<router-link
|
||||||
class="ui item"
|
class="ui item"
|
||||||
:to="{name: 'manage.library.artists'}"><translate translate-context="*/*/*/Noun">Artists</translate></router-link>
|
:to="{name: 'manage.library.artists'}"><translate translate-context="*/*/*/Noun">Artists</translate></router-link>
|
||||||
|
|
|
@ -343,7 +343,16 @@
|
||||||
{{ stats.media_total_size | humanSize }}
|
{{ stats.media_total_size | humanSize }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('account', object.full_username) }}">
|
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('account', object.full_username) }}">
|
||||||
|
|
|
@ -266,6 +266,16 @@
|
||||||
{{ stats.media_total_size | humanSize }}
|
{{ stats.media_total_size | humanSize }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('domain', object.name) }}">
|
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('domain', object.name) }}">
|
||||||
|
|
|
@ -84,7 +84,7 @@
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
class="basic item"
|
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"
|
:key="obj.target.type + obj.target.id"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
|
@ -112,7 +112,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-if="$store.state.auth.availablePermissions['library']" >
|
<template v-if="$store.state.auth.availablePermissions['library']" >
|
||||||
<div class="divider"></div>
|
<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>
|
<i class="wrench icon"></i>
|
||||||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
Loading…
Reference in New Issue