From b4731928fc7494abe966135ccdcce51bafb98f45 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Wed, 17 Apr 2019 14:17:59 +0200 Subject: [PATCH] UI To manage artists, albums, tracks --- api/funkwhale_api/common/fields.py | 2 +- api/funkwhale_api/common/filters.py | 26 ++ api/funkwhale_api/common/search.py | 36 +- api/funkwhale_api/common/views.py | 1 + api/funkwhale_api/federation/fields.py | 15 + api/funkwhale_api/federation/utils.py | 14 + api/funkwhale_api/manage/filters.py | 88 ++++- api/funkwhale_api/manage/serializers.py | 162 +++++++- api/funkwhale_api/manage/urls.py | 3 + api/funkwhale_api/manage/views.py | 151 ++++++++ api/funkwhale_api/music/models.py | 9 + api/tests/common/test_views.py | 8 +- api/tests/federation/test_api_filters.py | 15 + api/tests/manage/test_serializers.py | 157 ++++++++ api/tests/manage/test_views.py | 141 +++++++ api/tests/music/test_models.py | 6 + api/tests/users/oauth/test_api_permissions.py | 1 + front/src/components/Sidebar.vue | 34 +- front/src/components/common/ActionTable.vue | 5 +- front/src/components/common/EmptyState.vue | 2 +- front/src/components/library/Album.vue | 68 +++- front/src/components/library/Artist.vue | 72 +++- front/src/components/library/TrackBase.vue | 99 +++-- .../components/manage/library/AlbumsTable.vue | 218 +++++++++++ .../manage/library/ArtistsTable.vue | 208 ++++++++++ .../components/manage/library/TracksTable.vue | 218 +++++++++++ front/src/components/mixins/SmartSearch.vue | 13 +- front/src/components/mixins/Translations.vue | 1 + front/src/main.js | 6 + front/src/router/index.js | 56 ++- front/src/style/_main.scss | 11 +- front/src/views/admin/library/AlbumDetail.vue | 322 ++++++++++++++++ front/src/views/admin/library/AlbumsList.vue | 29 ++ .../src/views/admin/library/ArtistDetail.vue | 321 +++++++++++++++ front/src/views/admin/library/ArtistsList.vue | 29 ++ front/src/views/admin/library/Base.vue | 9 + front/src/views/admin/library/TrackDetail.vue | 364 ++++++++++++++++++ front/src/views/admin/library/TracksList.vue | 29 ++ .../views/admin/moderation/AccountsDetail.vue | 2 +- 39 files changed, 2836 insertions(+), 115 deletions(-) create mode 100644 front/src/components/manage/library/AlbumsTable.vue create mode 100644 front/src/components/manage/library/ArtistsTable.vue create mode 100644 front/src/components/manage/library/TracksTable.vue create mode 100644 front/src/views/admin/library/AlbumDetail.vue create mode 100644 front/src/views/admin/library/AlbumsList.vue create mode 100644 front/src/views/admin/library/ArtistDetail.vue create mode 100644 front/src/views/admin/library/ArtistsList.vue create mode 100644 front/src/views/admin/library/TrackDetail.vue create mode 100644 front/src/views/admin/library/TracksList.vue diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py index 47e673cb5b..b8e217ba4e 100644 --- a/api/funkwhale_api/common/fields.py +++ b/api/funkwhale_api/common/fields.py @@ -49,6 +49,6 @@ class SmartSearchFilter(django_filters.CharFilter): return qs try: cleaned = self.config.clean(value) - except forms.ValidationError: + except (forms.ValidationError): return qs.none() return search.apply(qs, cleaned) diff --git a/api/funkwhale_api/common/filters.py b/api/funkwhale_api/common/filters.py index 4825d3b5d8..364a1fba18 100644 --- a/api/funkwhale_api/common/filters.py +++ b/api/funkwhale_api/common/filters.py @@ -104,6 +104,31 @@ class MultipleQueryFilter(filters.TypedMultipleChoiceFilter): self.lookup_expr = "in" +def filter_target(value): + + config = { + "artist": ["artist", "target_id", int], + "album": ["album", "target_id", int], + "track": ["track", "target_id", int], + } + parts = value.lower().split(" ") + if parts[0].strip() not in config: + raise forms.ValidationError("Improper target") + + conf = config[parts[0].strip()] + + query = Q(target_content_type__model=conf[0]) + if len(parts) > 1: + _, lookup_field, validator = conf + try: + lookup_value = validator(parts[1].strip()) + except TypeError: + raise forms.ValidationError("Imparsable target id") + return query & Q(**{lookup_field: lookup_value}) + + return query + + class MutationFilter(filters.FilterSet): is_approved = NullBooleanFilter("is_approved") q = fields.SmartSearchFilter( @@ -116,6 +141,7 @@ class MutationFilter(filters.FilterSet): filter_fields={ "domain": {"to": "created_by__domain__name__iexact"}, "is_approved": get_null_boolean_filter("is_approved"), + "target": {"handler": filter_target}, "is_applied": {"to": "is_applied"}, }, ) diff --git a/api/funkwhale_api/common/search.py b/api/funkwhale_api/common/search.py index 622cb29dd1..cc046f758d 100644 --- a/api/funkwhale_api/common/search.py +++ b/api/funkwhale_api/common/search.py @@ -77,12 +77,15 @@ class SearchConfig: def clean(self, query): tokens = parse_query(query) cleaned_data = {} - cleaned_data["types"] = self.clean_types(filter_tokens(tokens, ["is"])) cleaned_data["search_query"] = self.clean_search_query( - filter_tokens(tokens, [None, "in"]) + filter_tokens(tokens, [None, "in"] + list(self.search_fields.keys())) ) - unhandled_tokens = [t for t in tokens if t["key"] not in [None, "is", "in"]] + unhandled_tokens = [ + t + for t in tokens + if t["key"] not in [None, "is", "in"] + list(self.search_fields.keys()) + ] cleaned_data["filter_query"] = self.clean_filter_query(unhandled_tokens) return cleaned_data @@ -95,8 +98,33 @@ class SearchConfig: } or set(self.search_fields.keys()) fields_subset = set(self.search_fields.keys()) & fields_subset to_fields = [self.search_fields[k]["to"] for k in fields_subset] + + specific_field_query = None + for token in tokens: + if token["key"] not in self.search_fields: + continue + to = self.search_fields[token["key"]]["to"] + try: + field = token["field"] + value = field.clean(token["value"]) + except KeyError: + # no cleaning to apply + value = token["value"] + q = Q(**{"{}__icontains".format(to): value}) + if not specific_field_query: + specific_field_query = q + else: + specific_field_query &= q query_string = " ".join([t["value"] for t in filter_tokens(tokens, [None])]) - return get_query(query_string, sorted(to_fields)) + unhandled_tokens_query = get_query(query_string, sorted(to_fields)) + + if specific_field_query and unhandled_tokens_query: + return unhandled_tokens_query & specific_field_query + elif specific_field_query: + return specific_field_query + elif unhandled_tokens_query: + return unhandled_tokens_query + return None def clean_filter_query(self, tokens): if not self.filter_fields or not tokens: diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py index 743c95095b..db39c56d1a 100644 --- a/api/funkwhale_api/common/views.py +++ b/api/funkwhale_api/common/views.py @@ -36,6 +36,7 @@ class MutationViewSet( lookup_field = "uuid" queryset = ( models.Mutation.objects.all() + .exclude(target_id=None) .order_by("-creation_date") .select_related("created_by", "approved_by") .prefetch_related("target") diff --git a/api/funkwhale_api/federation/fields.py b/api/funkwhale_api/federation/fields.py index 3523396dbc..8a8a1eb2de 100644 --- a/api/funkwhale_api/federation/fields.py +++ b/api/funkwhale_api/federation/fields.py @@ -1,6 +1,9 @@ +import django_filters + from rest_framework import serializers from . import models +from . import utils class ActorRelatedField(serializers.EmailField): @@ -16,3 +19,15 @@ class ActorRelatedField(serializers.EmailField): ) except models.Actor.DoesNotExist: raise serializers.ValidationError("Invalid actor name") + + +class DomainFromURLFilter(django_filters.CharFilter): + def __init__(self, *args, **kwargs): + self.url_field = kwargs.pop("url_field", "fid") + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + if not value: + return qs + query = utils.get_domain_query_from_url(value, self.url_field) + return qs.filter(query) diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index a32256921a..2bbfdf7fad 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -1,6 +1,7 @@ import unicodedata import re from django.conf import settings +from django.db.models import Q from funkwhale_api.common import session from funkwhale_api.moderation import models as moderation_models @@ -107,3 +108,16 @@ def retrieve_ap_object( serializer = serializer_class(data=data, context={"fetch_actor": actor}) serializer.is_valid(raise_exception=True) return serializer.save() + + +def get_domain_query_from_url(domain, url_field="fid"): + """ + Given a domain name and a field, will return a Q() object + to match objects that have this domain in the given field. + """ + + query = Q(**{"{}__startswith".format(url_field): "http://{}/".format(domain)}) + query = query | Q( + **{"{}__startswith".format(url_field): "https://{}/".format(domain)} + ) + return query diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index edae49f991..64a6473e0b 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,9 +1,11 @@ +from django import forms from django_filters import rest_framework as filters from funkwhale_api.common import fields from funkwhale_api.common import search 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 from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models @@ -24,6 +26,82 @@ class ManageUploadFilterSet(filters.FilterSet): fields = ["q", "track__album", "track__artist", "track"] +class ManageArtistFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "name": {"to": "name"}, + "fid": {"to": "fid"}, + "mbid": {"to": "mbid"}, + }, + filter_fields={ + "domain": { + "handler": lambda v: federation_utils.get_domain_query_from_url(v) + } + }, + ) + ) + + class Meta: + model = music_models.Artist + fields = ["q", "name", "mbid", "fid"] + + +class ManageAlbumFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "title": {"to": "title"}, + "fid": {"to": "fid"}, + "artist": {"to": "artist__name"}, + "mbid": {"to": "mbid"}, + }, + filter_fields={ + "artist_id": {"to": "artist_id", "field": forms.IntegerField()}, + "domain": { + "handler": lambda v: federation_utils.get_domain_query_from_url(v) + }, + }, + ) + ) + + class Meta: + model = music_models.Album + fields = ["q", "title", "mbid", "fid", "artist"] + + +class ManageTrackFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "title": {"to": "title"}, + "fid": {"to": "fid"}, + "mbid": {"to": "mbid"}, + "artist": {"to": "artist__name"}, + "album": {"to": "album__title"}, + "album_artist": {"to": "album__artist__name"}, + "copyright": {"to": "copyright"}, + }, + filter_fields={ + "album_id": {"to": "album_id", "field": forms.IntegerField()}, + "album_artist_id": { + "to": "album__artist_id", + "field": forms.IntegerField(), + }, + "artist_id": {"to": "artist_id", "field": forms.IntegerField()}, + "license": {"to": "license"}, + "domain": { + "handler": lambda v: federation_utils.get_domain_query_from_url(v) + }, + }, + ) + ) + + class Meta: + model = music_models.Track + fields = ["q", "title", "mbid", "fid", "artist", "album", "license"] + + class ManageDomainFilterSet(filters.FilterSet): q = fields.SearchFilter(search_fields=["name"]) @@ -60,7 +138,15 @@ class ManageActorFilterSet(filters.FilterSet): class ManageUserFilterSet(filters.FilterSet): - q = fields.SearchFilter(search_fields=["username", "email", "name"]) + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "name": {"to": "name"}, + "username": {"to": "username"}, + "email": {"to": "email"}, + } + ) + ) class Meta: model = users_models.User diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index ed50d86777..cf6a1eab4b 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -9,6 +9,7 @@ from funkwhale_api.federation import fields as federation_fields from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.moderation import models as moderation_models from funkwhale_api.music import models as music_models +from funkwhale_api.music import serializers as music_serializers from funkwhale_api.users import models as users_models from . import filters @@ -216,10 +217,7 @@ class ManageDomainActionSerializer(common_serializers.ActionSerializer): common_utils.on_commit(federation_tasks.purge_actors.delay, domains=list(ids)) -class ManageActorSerializer(serializers.ModelSerializer): - uploads_count = serializers.SerializerMethodField() - user = ManageUserSerializer() - +class ManageBaseActorSerializer(serializers.ModelSerializer): class Meta: model = federation_models.Actor fields = [ @@ -238,6 +236,17 @@ class ManageActorSerializer(serializers.ModelSerializer): "outbox_url", "shared_inbox_url", "manually_approves_followers", + ] + read_only_fields = ["creation_date", "instance_policy"] + + +class ManageActorSerializer(ManageBaseActorSerializer): + uploads_count = serializers.SerializerMethodField() + user = ManageUserSerializer() + + class Meta: + model = federation_models.Actor + fields = ManageBaseActorSerializer.Meta.fields + [ "uploads_count", "user", "instance_policy", @@ -339,3 +348,148 @@ class ManageInstancePolicySerializer(serializers.ModelSerializer): ) return instance + + +class ManageBaseArtistSerializer(serializers.ModelSerializer): + domain = serializers.CharField(source="domain_name") + + class Meta: + model = music_models.Artist + fields = ["id", "fid", "mbid", "name", "creation_date", "domain", "is_local"] + + +class ManageBaseAlbumSerializer(serializers.ModelSerializer): + cover = music_serializers.cover_field + domain = serializers.CharField(source="domain_name") + + class Meta: + model = music_models.Album + fields = [ + "id", + "fid", + "mbid", + "title", + "creation_date", + "release_date", + "cover", + "domain", + "is_local", + ] + + +class ManageNestedTrackSerializer(serializers.ModelSerializer): + domain = serializers.CharField(source="domain_name") + + class Meta: + model = music_models.Track + fields = [ + "id", + "fid", + "mbid", + "title", + "creation_date", + "position", + "disc_number", + "domain", + "is_local", + "copyright", + "license", + ] + + +class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer): + + tracks_count = serializers.SerializerMethodField() + + class Meta: + model = music_models.Album + fields = ManageBaseAlbumSerializer.Meta.fields + ["tracks_count"] + + def get_tracks_count(self, obj): + return getattr(obj, "tracks_count", None) + + +class ManageArtistSerializer(ManageBaseArtistSerializer): + albums = ManageNestedAlbumSerializer(many=True) + tracks = ManageNestedTrackSerializer(many=True) + attributed_to = ManageBaseActorSerializer() + + class Meta: + model = music_models.Artist + fields = ManageBaseArtistSerializer.Meta.fields + [ + "albums", + "tracks", + "attributed_to", + ] + + +class ManageNestedArtistSerializer(ManageBaseArtistSerializer): + pass + + +class ManageAlbumSerializer(ManageBaseAlbumSerializer): + tracks = ManageNestedTrackSerializer(many=True) + attributed_to = ManageBaseActorSerializer() + artist = ManageNestedArtistSerializer() + + class Meta: + model = music_models.Album + fields = ManageBaseAlbumSerializer.Meta.fields + [ + "artist", + "tracks", + "attributed_to", + ] + + +class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer): + artist = ManageNestedArtistSerializer() + + class Meta: + model = music_models.Album + fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"] + + +class ManageTrackSerializer(ManageNestedTrackSerializer): + artist = ManageNestedArtistSerializer() + album = ManageTrackAlbumSerializer() + attributed_to = ManageBaseActorSerializer() + uploads_count = serializers.SerializerMethodField() + + class Meta: + model = music_models.Track + fields = ManageNestedTrackSerializer.Meta.fields + [ + "artist", + "album", + "attributed_to", + "uploads_count", + ] + + def get_uploads_count(self, obj): + return getattr(obj, "uploads_count", None) + + +class ManageTrackActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageTrackFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + +class ManageAlbumActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageAlbumFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + +class ManageArtistActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageArtistFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 4c220fe0ef..f93667725a 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -8,6 +8,9 @@ federation_router.register(r"domains", views.ManageDomainViewSet, "domains") library_router = routers.SimpleRouter() library_router.register(r"uploads", views.ManageUploadViewSet, "uploads") +library_router.register(r"artists", views.ManageArtistViewSet, "artists") +library_router.register(r"albums", views.ManageAlbumViewSet, "albums") +library_router.register(r"tracks", views.ManageTrackViewSet, "tracks") moderation_router = routers.SimpleRouter() moderation_router.register( diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 588e66c589..6fc1a2f1e6 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,12 +1,18 @@ from rest_framework import mixins, response, viewsets from rest_framework import decorators as rest_decorators + +from django.db.models import Count, Prefetch, Q, Sum from django.shortcuts import get_object_or_404 +from funkwhale_api.common import models as common_models from funkwhale_api.common import preferences, decorators +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.history import models as history_models from funkwhale_api.music import models as music_models from funkwhale_api.moderation import models as moderation_models +from funkwhale_api.playlists import models as playlists_models from funkwhale_api.users import models as users_models @@ -45,6 +51,151 @@ class ManageUploadViewSet( return response.Response(result, status=200) +def get_stats(tracks, target): + data = {} + 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.values_list("library", flat=True).distinct().count() + data["uploads"] = uploads.count() + data["media_total_size"] = uploads.aggregate(v=Sum("size"))["v"] or 0 + data["media_downloaded_size"] = ( + uploads.with_file().aggregate(v=Sum("size"))["v"] or 0 + ) + return data + + +class ManageArtistViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + music_models.Artist.objects.all() + .order_by("-id") + .select_related("attributed_to") + .prefetch_related( + "tracks", + Prefetch( + "albums", + queryset=music_models.Album.objects.annotate( + tracks_count=Count("tracks") + ), + ), + ) + ) + serializer_class = serializers.ManageArtistSerializer + filterset_class = filters.ManageArtistFilterSet + required_scope = "instance:libraries" + ordering_fields = ["creation_date", "name"] + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + artist = self.get_object() + tracks = music_models.Track.objects.filter( + Q(artist=artist) | Q(album__artist=artist) + ) + data = get_stats(tracks, artist) + return response.Response(data, status=200) + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageArtistActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + +class ManageAlbumViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + music_models.Album.objects.all() + .order_by("-id") + .select_related("attributed_to", "artist") + .prefetch_related("tracks") + ) + serializer_class = serializers.ManageAlbumSerializer + filterset_class = filters.ManageAlbumFilterSet + required_scope = "instance:libraries" + ordering_fields = ["creation_date", "title", "release_date"] + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + album = self.get_object() + data = get_stats(album.tracks.all(), album) + return response.Response(data, status=200) + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageAlbumActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + +class ManageTrackViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + music_models.Track.objects.all() + .order_by("-id") + .select_related("attributed_to", "artist", "album__artist") + .annotate(uploads_count=Count("uploads")) + ) + serializer_class = serializers.ManageTrackSerializer + filterset_class = filters.ManageTrackFilterSet + required_scope = "instance:libraries" + ordering_fields = [ + "creation_date", + "title", + "album__release_date", + "position", + "disc_number", + ] + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + track = self.get_object() + data = get_stats(track.__class__.objects.filter(pk=track.pk), track) + return response.Response(data, status=200) + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageTrackActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + class ManageUserViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 9e0268504d..4b166be7c3 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -3,6 +3,7 @@ import logging import mimetypes import os import tempfile +import urllib.parse import uuid import markdown @@ -124,6 +125,14 @@ class APIModelMixin(models.Model): "https://{}/".format(d) ) + @property + def domain_name(self): + if not self.fid: + return + + parsed = urllib.parse.urlparse(self.fid) + return parsed.hostname + class License(models.Model): code = models.CharField(primary_key=True, max_length=100) diff --git a/api/tests/common/test_views.py b/api/tests/common/test_views.py index 9a03fb4292..d2b53b41f6 100644 --- a/api/tests/common/test_views.py +++ b/api/tests/common/test_views.py @@ -7,7 +7,9 @@ from funkwhale_api.common import tasks def test_can_detail_mutation(logged_in_api_client, factories): - mutation = factories["common.Mutation"](payload={}) + mutation = factories["common.Mutation"]( + payload={}, target=factories["music.Artist"]() + ) url = reverse("api:v1:mutations-detail", kwargs={"uuid": mutation.uuid}) response = logged_in_api_client.get(url) @@ -19,7 +21,9 @@ def test_can_detail_mutation(logged_in_api_client, factories): def test_can_list_mutations(logged_in_api_client, factories): - mutation = factories["common.Mutation"](payload={}) + mutation = factories["common.Mutation"]( + payload={}, target=factories["music.Artist"]() + ) url = reverse("api:v1:mutations-list") response = logged_in_api_client.get(url) diff --git a/api/tests/federation/test_api_filters.py b/api/tests/federation/test_api_filters.py index c6e70b6178..4cbf4293a3 100644 --- a/api/tests/federation/test_api_filters.py +++ b/api/tests/federation/test_api_filters.py @@ -1,3 +1,4 @@ +from funkwhale_api.federation import fields from funkwhale_api.federation import filters from funkwhale_api.federation import models @@ -7,3 +8,17 @@ def test_inbox_item_filter_before(factories): f = filters.InboxItemFilter({"before": 12}, queryset=models.InboxItem.objects.all()) assert str(f.qs.query) == str(expected.query) + + +def test_domain_from_url_filter(factories): + found = [ + factories["music.Artist"](fid="http://domain/test1"), + factories["music.Artist"](fid="https://domain/test2"), + ] + factories["music.Artist"](fid="http://domain2/test1") + factories["music.Artist"](fid="https://otherdomain/test2") + + queryset = found[0].__class__.objects.all().order_by("id") + field = fields.DomainFromURLFilter() + result = field.filter(queryset, "domain") + assert list(result) == found diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index aef8dc4ea9..64a26538f9 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -257,3 +257,160 @@ def test_instance_policy_serializer_purges_target_actor( assert getattr(policy, param) is False assert on_commit.call_count == 0 + + +def test_manage_artist_serializer(factories, now): + artist = factories["music.Artist"](attributed=True) + track = factories["music.Track"](artist=artist) + album = factories["music.Album"](artist=artist) + expected = { + "id": artist.id, + "domain": artist.domain_name, + "is_local": artist.is_local, + "fid": artist.fid, + "name": artist.name, + "mbid": artist.mbid, + "creation_date": artist.creation_date.isoformat().split("+")[0] + "Z", + "albums": [serializers.ManageNestedAlbumSerializer(album).data], + "tracks": [serializers.ManageNestedTrackSerializer(track).data], + "attributed_to": serializers.ManageBaseActorSerializer( + artist.attributed_to + ).data, + } + s = serializers.ManageArtistSerializer(artist) + + assert s.data == expected + + +def test_manage_nested_track_serializer(factories, now): + track = factories["music.Track"]() + expected = { + "id": track.id, + "domain": track.domain_name, + "is_local": track.is_local, + "fid": track.fid, + "title": track.title, + "mbid": track.mbid, + "creation_date": track.creation_date.isoformat().split("+")[0] + "Z", + "position": track.position, + "disc_number": track.disc_number, + "copyright": track.copyright, + "license": track.license, + } + s = serializers.ManageNestedTrackSerializer(track) + + assert s.data == expected + + +def test_manage_nested_album_serializer(factories, now): + album = factories["music.Album"]() + setattr(album, "tracks_count", 44) + expected = { + "id": album.id, + "domain": album.domain_name, + "is_local": album.is_local, + "fid": album.fid, + "title": album.title, + "mbid": album.mbid, + "creation_date": album.creation_date.isoformat().split("+")[0] + "Z", + "release_date": album.release_date.isoformat(), + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, + "tracks_count": 44, + } + s = serializers.ManageNestedAlbumSerializer(album) + + assert s.data == expected + + +def test_manage_nested_artist_serializer(factories, now): + artist = factories["music.Artist"]() + expected = { + "id": artist.id, + "domain": artist.domain_name, + "is_local": artist.is_local, + "fid": artist.fid, + "name": artist.name, + "mbid": artist.mbid, + "creation_date": artist.creation_date.isoformat().split("+")[0] + "Z", + } + s = serializers.ManageNestedArtistSerializer(artist) + + assert s.data == expected + + +def test_manage_album_serializer(factories, now): + album = factories["music.Album"](attributed=True) + track = factories["music.Track"](album=album) + expected = { + "id": album.id, + "domain": album.domain_name, + "is_local": album.is_local, + "fid": album.fid, + "title": album.title, + "mbid": album.mbid, + "creation_date": album.creation_date.isoformat().split("+")[0] + "Z", + "release_date": album.release_date.isoformat(), + "cover": { + "original": album.cover.url, + "square_crop": album.cover.crop["400x400"].url, + "medium_square_crop": album.cover.crop["200x200"].url, + "small_square_crop": album.cover.crop["50x50"].url, + }, + "artist": serializers.ManageNestedArtistSerializer(album.artist).data, + "tracks": [serializers.ManageNestedTrackSerializer(track).data], + "attributed_to": serializers.ManageBaseActorSerializer( + album.attributed_to + ).data, + } + s = serializers.ManageAlbumSerializer(album) + + assert s.data == expected + + +def test_manage_track_serializer(factories, now): + track = factories["music.Track"](attributed=True) + setattr(track, "uploads_count", 44) + expected = { + "id": track.id, + "domain": track.domain_name, + "is_local": track.is_local, + "fid": track.fid, + "title": track.title, + "mbid": track.mbid, + "disc_number": track.disc_number, + "position": track.position, + "copyright": track.copyright, + "license": track.license, + "creation_date": track.creation_date.isoformat().split("+")[0] + "Z", + "artist": serializers.ManageNestedArtistSerializer(track.artist).data, + "album": serializers.ManageTrackAlbumSerializer(track.album).data, + "attributed_to": serializers.ManageBaseActorSerializer( + track.attributed_to + ).data, + "uploads_count": 44, + } + s = serializers.ManageTrackSerializer(track) + + assert s.data == expected + + +@pytest.mark.parametrize( + "factory, serializer_class", + [ + ("music.Track", serializers.ManageTrackActionSerializer), + ("music.Album", serializers.ManageAlbumActionSerializer), + ("music.Artist", serializers.ManageArtistActionSerializer), + ], +) +def test_action_serializer_delete(factory, serializer_class, factories): + objects = factories[factory].create_batch(size=5) + s = serializer_class(queryset=None) + + s.handle_delete(objects[0].__class__.objects.all()) + + assert objects[0].__class__.objects.count() == 0 diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index 673c39cbc9..923d331d8c 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -148,3 +148,144 @@ def test_instance_policy_create(superuser_api_client, factories): policy = domain.instance_policy assert policy.actor == actor + + +def test_artist_list(factories, superuser_api_client, settings): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == artist.id + + +def test_artist_detail(factories, superuser_api_client): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-detail", kwargs={"pk": artist.pk}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == artist.id + + +def test_artist_detail_stats(factories, superuser_api_client): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-stats", kwargs={"pk": artist.pk}) + response = superuser_api_client.get(url) + expected = { + "libraries": 0, + "uploads": 0, + "listenings": 0, + "playlists": 0, + "mutations": 0, + "track_favorites": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_artist_delete(factories, superuser_api_client): + artist = factories["music.Artist"]() + url = reverse("api:v1:manage:library:artists-detail", kwargs={"pk": artist.pk}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 + + +def test_album_list(factories, superuser_api_client, settings): + album = factories["music.Album"]() + factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-list") + response = superuser_api_client.get( + url, {"q": 'artist:"{}"'.format(album.artist.name)} + ) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == album.id + + +def test_album_detail(factories, superuser_api_client): + album = factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-detail", kwargs={"pk": album.pk}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == album.id + + +def test_album_detail_stats(factories, superuser_api_client): + album = factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-stats", kwargs={"pk": album.pk}) + response = superuser_api_client.get(url) + expected = { + "libraries": 0, + "uploads": 0, + "listenings": 0, + "playlists": 0, + "mutations": 0, + "track_favorites": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_album_delete(factories, superuser_api_client): + album = factories["music.Album"]() + url = reverse("api:v1:manage:library:albums-detail", kwargs={"pk": album.pk}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 + + +def test_track_list(factories, superuser_api_client, settings): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == track.id + + +def test_track_detail(factories, superuser_api_client): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-detail", kwargs={"pk": track.pk}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == track.id + + +def test_track_detail_stats(factories, superuser_api_client): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-stats", kwargs={"pk": track.pk}) + response = superuser_api_client.get(url) + expected = { + "libraries": 0, + "uploads": 0, + "listenings": 0, + "playlists": 0, + "mutations": 0, + "track_favorites": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_track_delete(factories, superuser_api_client): + track = factories["music.Track"]() + url = reverse("api:v1:manage:library:tracks-detail", kwargs={"pk": track.pk}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 diff --git a/api/tests/music/test_models.py b/api/tests/music/test_models.py index 4446de7dd9..5aa29b3cc4 100644 --- a/api/tests/music/test_models.py +++ b/api/tests/music/test_models.py @@ -548,3 +548,9 @@ def test_api_model_mixin_is_local(federation_hostname, fid, expected, settings): settings.FEDERATION_HOSTNAME = federation_hostname obj = models.Track(fid=fid) assert obj.is_local is expected + + +def test_api_model_mixin_domain_name(): + obj = models.Track(fid="https://test.domain:543/something") + + assert obj.domain_name == "test.domain" diff --git a/api/tests/users/oauth/test_api_permissions.py b/api/tests/users/oauth/test_api_permissions.py index aaac8430b5..e73d3a3f91 100644 --- a/api/tests/users/oauth/test_api_permissions.py +++ b/api/tests/users/oauth/test_api_permissions.py @@ -53,6 +53,7 @@ from funkwhale_api.users.oauth import scopes "read:instance:policies", "get", ), + ("api:v1:manage:library:artists-list", {}, "read:instance:libraries", "get"), ], ) def test_views_permissions( diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 213ab92ce9..8b183b2158 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -80,16 +80,15 @@ <header class="header"><translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate></header> <div class="menu"> <router-link - v-if="$store.state.auth.availablePermissions['settings']" - class="item" - :to="{path: '/manage/settings'}"> - <i class="settings icon"></i><translate translate-context="*/*/*/Noun">Settings</translate> - </router-link> - <router-link - v-if="$store.state.auth.availablePermissions['settings']" + v-if="$store.state.auth.availablePermissions['library']" class="item" - :to="{name: 'manage.users.users.list'}"> - <i class="users icon"></i><translate translate-context="*/*/*/Noun">Users</translate> + :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"> + <i class="book icon"></i><translate translate-context="*/*/*">Library</translate> + <div + v-if="$store.state.ui.notifications.pendingReviewEdits > 0" + :title="labels.pendingReviewEdits" + :class="['ui', 'teal', 'label']"> + {{ $store.state.ui.notifications.pendingReviewEdits }}</div> </router-link> <router-link v-if="$store.state.auth.availablePermissions['moderation']" @@ -98,15 +97,16 @@ <i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate> </router-link> <router-link - v-if="$store.state.auth.availablePermissions['library']" + v-if="$store.state.auth.availablePermissions['settings']" class="item" - :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"> - <i class="book icon"></i><translate translate-context="*/*/*">Library</translate> - <div - v-if="$store.state.ui.notifications.pendingReviewEdits > 0" - :title="labels.pendingReviewEdits" - :class="['ui', 'teal', 'label']"> - {{ $store.state.ui.notifications.pendingReviewEdits }}</div> + :to="{name: 'manage.users.users.list'}"> + <i class="users icon"></i><translate translate-context="*/*/*/Noun">Users</translate> + </router-link> + <router-link + v-if="$store.state.auth.availablePermissions['settings']" + class="item" + :to="{path: '/manage/settings'}"> + <i class="settings icon"></i><translate translate-context="*/*/*/Noun">Settings</translate> </router-link> </div> </div> diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 9bbf18f85f..1a43a5f9ff 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -30,7 +30,7 @@ <div class="field"> <dangerous-button v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']" - confirm-color="green" + :confirm-color="currentAction.confirmColor || 'green'" color="" @confirm="launchAction"> <translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate> @@ -44,7 +44,8 @@ </translate> </p> <p slot="modal-content"> - <translate translate-context="Modal/*/Paragraph">This may affect a lot of elements or have irreversible consequences, please double check this is really what you want.</translate> + <template v-if="currentAction.confirmationMessage">{{ currentAction.confirmationMessage }}</template> + <translate v-else translate-context="Modal/*/Paragraph">This may affect a lot of elements or have irreversible consequences, please double check this is really what you want.</translate> </p> <div slot="modal-confirm"><translate translate-context="Modal/*/Button.Label/Short, Verb">Launch</translate></div> </dangerous-button> diff --git a/front/src/components/common/EmptyState.vue b/front/src/components/common/EmptyState.vue index cc9f32ca1e..862700679e 100644 --- a/front/src/components/common/EmptyState.vue +++ b/front/src/components/common/EmptyState.vue @@ -29,7 +29,7 @@ export default { } </script> -<style> +<style scoped> .ui.small.placeholder.segment { min-height: auto; } diff --git a/front/src/components/library/Album.vue b/front/src/components/library/Album.vue index 0031aeed84..1a5f6b50da 100644 --- a/front/src/components/library/Album.vue +++ b/front/src/components/library/Album.vue @@ -14,26 +14,15 @@ </div> </h2> <div class="ui hidden divider"></div> - <play-button class="orange" :tracks="album.tracks"> - <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> - </play-button> + <div class="header-buttons"> + + <div class="ui buttons"> + <play-button class="orange" :tracks="album.tracks"> + <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> + </play-button> + </div> - <a :href="wikipediaUrl" target="_blank" class="ui icon labeled button"> - <i class="wikipedia w icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate> - </a> - <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" class="ui icon labeled button"> - <i class="external icon"></i> - <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> - </a> - <template v-if="publicLibraries.length > 0"> - <button - @click="showEmbedModal = !showEmbedModal" - class="ui button icon labeled"> - <i class="code icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> - </button> - <modal :show.sync="showEmbedModal"> + <modal v-if="publicLibraries.length > 0" :show.sync="showEmbedModal"> <div class="header"> <translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate> </div> @@ -49,7 +38,46 @@ </div> </div> </modal> - </template> + <div class="ui buttons"> + <button class="ui button" @click="$refs.dropdown.click()"> + <translate translate-context="*/*/Button.Label/Noun">More…</translate> + </button> + <div class="ui floating dropdown icon button" ref="dropdown" v-dropdown> + <i class="dropdown icon"></i> + <div class="menu"> + <div + role="button" + v-if="publicLibraries.length > 0" + @click="showEmbedModal = !showEmbedModal" + class="basic item"> + <i class="code icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> + </div> + <a :href="wikipediaUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="wikipedia w icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate> + </a> + <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="external icon"></i> + <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> + </a> + <div class="divider"></div> + <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: album.id}}"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + <a + v-if="$store.state.auth.profile.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${album.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> + </div> + </div> + </div> + </div> </div> </section> <template v-if="discs && discs.length > 1"> diff --git a/front/src/components/library/Artist.vue b/front/src/components/library/Artist.vue index 34071e746d..4d6e10e2d7 100644 --- a/front/src/components/library/Artist.vue +++ b/front/src/components/library/Artist.vue @@ -22,27 +22,18 @@ </div> </h2> <div class="ui hidden divider"></div> - <radio-button type="artist" :object-id="artist.id"></radio-button> - <play-button :is-playable="isPlayable" class="orange" :artist="artist"> - <translate translate-context="Content/Artist/Button.Label/Verb">Play all albums</translate> - </play-button> + <div class="header-buttons"> + <div class="ui buttons"> + <radio-button type="artist" :object-id="artist.id"></radio-button> - <a :href="wikipediaUrl" target="_blank" class="ui icon labeled button"> - <i class="wikipedia w icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate> - </a> - <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" class="ui button"> - <i class="external icon"></i> - <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> - </a> - <template v-if="publicLibraries.length > 0"> - <button - @click="showEmbedModal = !showEmbedModal" - class="ui button icon labeled"> - <i class="code icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> - </button> - <modal :show.sync="showEmbedModal"> + </div> + <div class="ui buttons"> + <play-button :is-playable="isPlayable" class="orange" :artist="artist"> + <translate translate-context="Content/Artist/Button.Label/Verb">Play all albums</translate> + </play-button> + </div> + + <modal :show.sync="showEmbedModal" v-if="publicLibraries.length > 0"> <div class="header"> <translate translate-context="Popup/Artist/Title/Verb">Embed this artist work on your website</translate> </div> @@ -58,7 +49,46 @@ </div> </div> </modal> - </template> + <div class="ui buttons"> + <button class="ui button" @click="$refs.dropdown.click()"> + <translate translate-context="*/*/Button.Label/Noun">More…</translate> + </button> + <div class="ui floating dropdown icon button" ref="dropdown" v-dropdown> + <i class="dropdown icon"></i> + <div class="menu"> + <div + role="button" + v-if="publicLibraries.length > 0" + @click="showEmbedModal = !showEmbedModal" + class="basic item"> + <i class="code icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> + </div> + <a :href="wikipediaUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="wikipedia w icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate> + </a> + <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="external icon"></i> + <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> + </a> + <div class="divider"></div> + <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: artist.id}}"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + <a + v-if="$store.state.auth.profile.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${artist.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> + </div> + </div> + </div> + </div> </div> </section> <div class="ui small text container" v-if="contentFilter"> diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index cd4572f5b6..6df2f7aa0c 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -21,33 +21,27 @@ </div> </div> </h2> + <div class="header-buttons"> + <div class="ui buttons"> + <play-button class="orange" :track="track"> + <translate translate-context="*/Queue/Button.Label/Short, Verb">Play</translate> + </play-button> + </div> + <div class="ui buttons"> + <track-favorite-icon :track="track" :button="true"></track-favorite-icon> + </div> + <div class="ui buttons"> + <track-playlist-icon :button="true" v-if="$store.state.auth.authenticated" :track="track"></track-playlist-icon> + </div> - <play-button class="orange" :track="track"> - <translate translate-context="*/Queue/Button.Label/Short, Verb">Play</translate> - </play-button> - <track-favorite-icon :track="track" :button="true"></track-favorite-icon> - <track-playlist-icon :button="true" v-if="$store.state.auth.authenticated" :track="track"></track-playlist-icon> + <div class="ui buttons"> + <a v-if="upload" :href="downloadUrl" target="_blank" class="ui icon labeled button"> + <i class="download icon"></i> + <translate translate-context="Content/Track/Link/Verb">Download</translate> + </a> + </div> - <a :href="wikipediaUrl" target="_blank" class="ui icon labeled button"> - <i class="wikipedia w icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate> - </a> - <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" class="ui icon labeled button"> - <i class="external icon"></i> - <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> - </a> - <a v-if="upload" :href="downloadUrl" target="_blank" class="ui icon labeled button"> - <i class="download icon"></i> - <translate translate-context="Content/Track/Link/Verb">Download</translate> - </a> - <template v-if="publicLibraries.length > 0"> - <button - @click="showEmbedModal = !showEmbedModal" - class="ui icon labeled button"> - <i class="code icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> - </button> - <modal :show.sync="showEmbedModal"> + <modal v-if="publicLibraries.length > 0" :show.sync="showEmbedModal"> <div class="header"> <translate translate-context="Popup/Track/Title">Embed this track on your website</translate> </div> @@ -63,14 +57,53 @@ </div> </div> </modal> - </template> - <router-link - v-if="track.is_local" - :to="{name: 'library.tracks.edit', params: {id: track.id }}" - class="ui icon labeled button"> - <i class="edit icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> - </router-link> + <div class="ui buttons"> + <button class="ui button" @click="$refs.dropdown.click()"> + <translate translate-context="*/*/Button.Label/Noun">More…</translate> + </button> + <div class="ui floating dropdown icon button" ref="dropdown" v-dropdown> + <i class="dropdown icon"></i> + <div class="menu"> + <div + role="button" + v-if="publicLibraries.length > 0" + @click="showEmbedModal = !showEmbedModal" + class="basic item"> + <i class="code icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> + </div> + <a :href="wikipediaUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="wikipedia w icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate> + </a> + <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="external icon"></i> + <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> + </a> + <router-link + v-if="track.is_local" + :to="{name: 'library.tracks.edit', params: {id: track.id }}" + class="basic item"> + <i class="edit icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> + <div class="divider"></div> + <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.tracks.detail', params: {id: track.id}}"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + <a + v-if="$store.state.auth.profile.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/track/${track.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> + </div> + </div> + </div> + </div> </div> </section> <router-view v-if="track" @libraries-loaded="libraries = $event" :track="track" :object="track" object-type="track" :key="$route.fullPath"></router-view> diff --git a/front/src/components/manage/library/AlbumsTable.vue b/front/src/components/manage/library/AlbumsTable.vue new file mode 100644 index 0000000000..3af2da4db9 --- /dev/null +++ b/front/src/components/manage/library/AlbumsTable.vue @@ -0,0 +1,218 @@ +<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="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/albums/action/" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate translate-context="*/*/*">Title</translate></th> + <th><translate translate-context="*/*/*">Artist</translate></th> + <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> + <th><translate translate-context="*/*/*">Tracks</translate></th> + <th><translate translate-context="Content/*/*/Noun">Release date</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.library.albums.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link> + </td> + <td> + <router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.artist.id }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('artist', scope.obj.artist.name)" :title="scope.obj.artist.name">{{ scope.obj.artist.name }}</span> + </td> + <td> + <template v-if="!scope.obj.is_local"> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.domain }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.domain)" :title="scope.obj.domain">{{ scope.obj.domain }}</span> + </template> + <span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.domain)"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + </td> + <td> + {{ scope.obj.tracks.length }} + </td> + <td> + <human-date v-if="scope.obj.release_date" :date="scope.obj.release_date"></human-date> + <translate v-else translate-context="*/*/*">N/A</translate> + + </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'], + ['release_date', 'release_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/library/albums/', {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, title, artist, MusicBrainz ID…') + } + }, + 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 albums will be removed, as well as associated tracks, uploads, 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> diff --git a/front/src/components/manage/library/ArtistsTable.vue b/front/src/components/manage/library/ArtistsTable.vue new file mode 100644 index 0000000000..84c873832d --- /dev/null +++ b/front/src/components/manage/library/ArtistsTable.vue @@ -0,0 +1,208 @@ +<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="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="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.library.artists.detail', params: {id: scope.obj.id }}">{{ scope.obj.name }}</router-link> + </td> + <td> + <template v-if="!scope.obj.is_local"> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.domain }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.domain)" :title="scope.obj.domain">{{ scope.obj.domain }}</span> + </template> + <span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.domain)"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + </td> + <td> + {{ scope.obj.albums.length }} + </td> + <td> + {{ scope.obj.tracks.length }} + </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/library/artists/', {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, MusicBrainz ID…') + } + }, + 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> diff --git a/front/src/components/manage/library/TracksTable.vue b/front/src/components/manage/library/TracksTable.vue new file mode 100644 index 0000000000..a702d8e25c --- /dev/null +++ b/front/src/components/manage/library/TracksTable.vue @@ -0,0 +1,218 @@ +<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="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/tracks/action/" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate translate-context="*/*/*">Title</translate></th> + <th><translate translate-context="*/*/*">Album</translate></th> + <th><translate translate-context="*/*/*">Artist</translate></th> + <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> + <th><translate translate-context="Content/*/*/Noun">License</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.library.tracks.detail', params: {id: scope.obj.id }}">{{ scope.obj.title }}</router-link> + </td> + <td> + <router-link :to="{name: 'manage.library.albums.detail', params: {id: scope.obj.album.id }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('album_id', scope.obj.album.id)" :title="scope.obj.album.title">{{ scope.obj.album.title }}</span> + </td> + <td> + <router-link :to="{name: 'manage.library.artists.detail', params: {id: scope.obj.artist.id }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('artist_id', scope.obj.artist.id)" :title="scope.obj.artist.name">{{ scope.obj.artist.name }}</span> + </td> + <td> + <template v-if="!scope.obj.is_local"> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.domain }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.domain)" :title="scope.obj.domain">{{ scope.obj.domain }}</span> + </template> + <span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.domain)"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + </td> + <td> + <span role="button" v-if="scope.obj.license" class="discrete link" @click="addSearchToken('license', scope.obj.license)" :title="scope.obj.license">{{ scope.obj.license }}</span> + <translate v-else translate-context="*/*/*">N/A</translate> + </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'], + ] + } + }, + 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/library/tracks/', {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, title, artist, album, MusicBrainz ID…') + } + }, + 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 tracks will be removed, as well as associated uploads, 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> diff --git a/front/src/components/mixins/SmartSearch.vue b/front/src/components/mixins/SmartSearch.vue index 8b03becbbd..ef45060396 100644 --- a/front/src/components/mixins/SmartSearch.vue +++ b/front/src/components/mixins/SmartSearch.vue @@ -18,6 +18,7 @@ export default { return fallback }, addSearchToken (key, value) { + value = String(value) if (!value) { // we remove existing matching tokens, if any this.search.tokens = this.search.tokens.filter(t => { @@ -45,17 +46,19 @@ export default { }, 'search.tokens': { handler (newValue) { - this.search.query = compileTokens(newValue) - this.page = 1 - this.fetchData() + let newQuery = compileTokens(newValue) if (this.updateUrl) { let params = {} - if (this.search.query) { - params.q = this.search.query + if (newQuery) { + params.q = newQuery } this.$router.replace({ query: params }) + } else { + this.search.query = newQuery + this.page = 1 + this.fetchData() } }, deep: true diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index 2bbc64c83f..b2bd455cc6 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -16,6 +16,7 @@ export default { }, filters: { creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'), + release_date: this.$pgettext('Content/*/*/Noun', 'Release date'), first_seen: this.$pgettext('Content/Moderation/Dropdown/Noun', 'First seen date'), last_seen: this.$pgettext('Content/Moderation/Dropdown/Noun', 'Last seen date'), modification_date: this.$pgettext('Content/Playlist/Dropdown/Noun', 'Modification date'), diff --git a/front/src/main.js b/front/src/main.js index 31aa07d031..959b776b3d 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -4,6 +4,7 @@ import logger from '@/logging' logger.default.info('Loading environment:', process.env.NODE_ENV) logger.default.debug('Environment variables:', process.env) +import jQuery from "jquery" import Vue from 'vue' import App from './App' @@ -60,6 +61,11 @@ Vue.config.productionTip = false Vue.directive('title', function (el, binding) { store.commit('ui/pageTitle', binding.value) }) +Vue.directive('dropdown', function (el, binding) { + jQuery(el).dropdown({ + selectOnKeydown: false, + }) +}) axios.interceptors.request.use(function (config) { // Do something before request is sent if (store.state.auth.token) { diff --git a/front/src/router/index.js b/front/src/router/index.js index 8703eedead..bee09f2028 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -33,6 +33,12 @@ import Favorites from '@/components/favorites/List' import AdminSettings from '@/views/admin/Settings' import AdminLibraryBase from '@/views/admin/library/Base' import AdminLibraryEditsList from '@/views/admin/library/EditsList' +import AdminLibraryArtistsList from '@/views/admin/library/ArtistsList' +import AdminLibraryArtistsDetail from '@/views/admin/library/ArtistDetail' +import AdminLibraryAlbumsList from '@/views/admin/library/AlbumsList' +import AdminLibraryAlbumDetail from '@/views/admin/library/AlbumDetail' +import AdminLibraryTracksList from '@/views/admin/library/TracksList' +import AdminLibraryTrackDetail from '@/views/admin/library/TrackDetail' import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersList from '@/views/admin/users/UsersList' import AdminInvitationsList from '@/views/admin/users/InvitationsList' @@ -244,7 +250,55 @@ export default new Router({ defaultQuery: route.query.q, } } - } + }, + { + path: 'artists', + name: 'manage.library.artists', + component: AdminLibraryArtistsList, + props: (route) => { + return { + defaultQuery: route.query.q, + } + } + }, + { + path: 'artists/:id', + name: 'manage.library.artists.detail', + component: AdminLibraryArtistsDetail, + props: true + }, + { + path: 'albums', + name: 'manage.library.albums', + component: AdminLibraryAlbumsList, + props: (route) => { + return { + defaultQuery: route.query.q, + } + } + }, + { + path: 'albums/:id', + name: 'manage.library.albums.detail', + component: AdminLibraryAlbumDetail, + props: true + }, + { + path: 'tracks', + name: 'manage.library.tracks', + component: AdminLibraryTracksList, + props: (route) => { + return { + defaultQuery: route.query.q, + } + } + }, + { + path: 'tracks/:id', + name: 'manage.library.tracks.detail', + component: AdminLibraryTrackDetail, + props: true + }, ] }, { diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index cd12dda2f2..c1ddb3628d 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -231,8 +231,15 @@ body { justify-content: center; } -.segment-content .button { - margin: 0.5em; +.header-buttons > .buttons { + display: inline-block; + padding: 0.2em; + margin: 0; + font-size: 1em; + .buttons { + margin: 0; + } + } a { diff --git a/front/src/views/admin/library/AlbumDetail.vue b/front/src/views/admin/library/AlbumDetail.vue new file mode 100644 index 0000000000..3215da1011 --- /dev/null +++ b/front/src/views/admin/library/AlbumDetail.vue @@ -0,0 +1,322 @@ +<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.name"> + <div class="ui stackable one column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <img v-if="object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)"> + <img v-else src="../../../assets/audio/default-cover.png"> + <div class="content"> + {{ object.title | truncate(100) }} + <div class="sub header"> + <template v-if="object.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> + <div class="header-buttons"> + + <div class="ui icon buttons"> + <router-link class="ui labeled icon button" :to="{name: 'library.albums.detail', params: {id: object.id }}"> + <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.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${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> + <a class="basic item" v-if="object.mbid" :href="`https://musicbrainz.org/release/${object.mbid}`" target="_blank" rel="noopener noreferrer"> + <i class="external icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate> + </a> + <a class="basic item" :href="object.url || object.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"> + <router-link + v-if="object.is_local" + :to="{name: 'library.albums.edit', params: {id: object.id }}" + class="ui labeled icon button"> + <i class="edit icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> + </div> + <div class="ui buttons"> + <dangerous-button + :class="['ui', {loading: isLoading}, 'basic button']" + :action="remove"> + <translate translate-context="*/*/*/Verb">Delete</translate> + <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The album will be removed, as well as associated uploads, tracks, favorites and listening history. 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">Album data</translate> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Title</translate> + </td> + <td> + {{ object.title }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.artists.detail', params: {id: object.artist.id }}"> + <translate translate-context="*/*/*/Noun">Artist</translate> + </router-link> + </td> + <td> + {{ object.artist.name }} + </td> + </tr> + <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 v-if="!object.is_local"> + <td> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </td> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> + {{ object.domain }} + </router-link> + </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="*/*/*/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.library.edits', query: {q: getQuery('target', 'album ' + object.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> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </td> + <td> + {{ stats.libraries }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </td> + <td> + {{ stats.uploads }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('album_id', object.id) }}"> + <translate translate-context="*/*/*">Tracks</translate> + </router-link> + </td> + <td> + {{ object.tracks.length }} + </td> + </tr> + </tbody> + </table> + + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" + + +export default { + props: ["id"], + 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/library/albums/${this.id}/` + axios.get(url).then(response => { + self.object = response.data + self.isLoading = false + }) + }, + fetchStats() { + var self = this + this.isLoadingStats = true + let url = `manage/library/albums/${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/library/albums/${this.id}/` + axios.delete(url).then(response => { + self.$router.push({name: 'manage.library.albums'}) + }) + }, + 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> diff --git a/front/src/views/admin/library/AlbumsList.vue b/front/src/views/admin/library/AlbumsList.vue new file mode 100644 index 0000000000..650b4d6938 --- /dev/null +++ b/front/src/views/admin/library/AlbumsList.vue @@ -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> + <albums-table :update-url="true" :default-query="defaultQuery"></albums-table> + </section> + </main> +</template> + +<script> +import AlbumsTable from "@/components/manage/library/AlbumsTable" + +export default { + components: { + AlbumsTable + }, + props: { + defaultQuery: {type: String, required: false}, + }, + computed: { + labels() { + return { + title: this.$pgettext('*/*/*', 'Albums') + } + } + } +} +</script> diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue new file mode 100644 index 0000000000..bfbd414a17 --- /dev/null +++ b/front/src/views/admin/library/ArtistDetail.vue @@ -0,0 +1,321 @@ +<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.name"> + <div class="ui stackable one column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <i class="circular inverted user icon"></i> + <div class="content"> + {{ object.name | truncate(100) }} + <div class="sub header"> + <template v-if="object.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> + <div class="header-buttons"> + + <div class="ui icon buttons"> + <router-link class="ui labeled icon button" :to="{name: 'library.artists.detail', params: {id: object.id }}"> + <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.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${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> + <a class="basic item" v-if="object.mbid" :href="`https://musicbrainz.org/artist/${object.mbid}`" target="_blank" rel="noopener noreferrer"> + <i class="external icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate> + </a> + <a class="basic item" :href="object.url || object.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"> + <router-link + v-if="object.is_local" + :to="{name: 'library.artists.edit', params: {id: object.id }}" + class="ui labeled icon button"> + <i class="edit icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> + </div> + <div class="ui buttons"> + <dangerous-button + :class="['ui', {loading: isLoading}, 'basic button']" + :action="remove"> + <translate translate-context="*/*/*/Verb">Delete</translate> + <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this artist?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The artist will be removed, as well as associated uploads, tracks, albums, favorites and listening history. 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">Artist data</translate> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Name</translate> + </td> + <td> + {{ object.name }} + </td> + </tr> + <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 v-if="!object.is_local"> + <td> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </td> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> + {{ object.domain }} + </router-link> + </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="*/*/*/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.library.edits', query: {q: getQuery('target', 'artist ' + object.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> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </td> + <td> + {{ stats.libraries }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </td> + <td> + {{ stats.uploads }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.albums', query: {q: getQuery('artist_id', object.id) }}"> + <translate translate-context="*/*/*">Albums</translate> + </router-link> + </td> + <td> + {{ object.albums.length }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('artist_id', object.id) }}"> + <translate translate-context="*/*/*">Tracks</translate> + </router-link> + </td> + <td> + {{ object.tracks.length }} + </td> + </tr> + </tbody> + </table> + + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" + + +export default { + props: ["id"], + 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/library/artists/${this.id}/` + axios.get(url).then(response => { + self.object = response.data + self.isLoading = false + }) + }, + fetchStats() { + var self = this + this.isLoadingStats = true + let url = `manage/library/artists/${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/library/artists/${this.id}/` + axios.delete(url).then(response => { + self.$router.push({name: 'manage.library.artists'}) + }) + }, + 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> diff --git a/front/src/views/admin/library/ArtistsList.vue b/front/src/views/admin/library/ArtistsList.vue new file mode 100644 index 0000000000..2a5932796e --- /dev/null +++ b/front/src/views/admin/library/ArtistsList.vue @@ -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> + <artists-table :update-url="true" :default-query="defaultQuery"></artists-table> + </section> + </main> +</template> + +<script> +import ArtistsTable from "@/components/manage/library/ArtistsTable" + +export default { + components: { + ArtistsTable + }, + props: { + defaultQuery: {type: String, required: false}, + }, + computed: { + labels() { + return { + title: this.$pgettext('*/*/*', 'Artists') + } + } + } +} +</script> diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index b521e8f6ec..4b7048a17b 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -4,6 +4,15 @@ <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.library.artists'}"><translate translate-context="*/*/*">Artists</translate></router-link> + <router-link + class="ui item" + :to="{name: 'manage.library.albums'}"><translate translate-context="*/*/*">Albums</translate></router-link> + <router-link + class="ui item" + :to="{name: 'manage.library.tracks'}"><translate translate-context="*/*/*">Tracks</translate></router-link> </nav> <router-view :key="$route.fullPath"></router-view> </div> diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue new file mode 100644 index 0000000000..6e0c9f65e2 --- /dev/null +++ b/front/src/views/admin/library/TrackDetail.vue @@ -0,0 +1,364 @@ +<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.name"> + <div class="ui stackable one column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <i class="circular inverted user icon"></i> + <div class="content"> + {{ object.title | truncate(100) }} + <div class="sub header"> + <template v-if="object.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> + <div class="header-buttons"> + + <div class="ui icon buttons"> + <router-link class="ui icon labeled button" :to="{name: 'library.tracks.detail', params: {id: object.id }}"> + <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.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/track/${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> + <a class="basic item" v-if="object.mbid" :href="`https://musicbrainz.org/recording/${object.mbid}`" target="_blank" rel="noopener noreferrer"> + <i class="external icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open on MusicBrainz</translate> + </a> + <a class="basic item" :href="object.url || object.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"> + <router-link + v-if="object.is_local" + :to="{name: 'library.tracks.edit', params: {id: object.id }}" + class="ui labeled icon button"> + <i class="edit icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> + </div> + <div class="ui buttons"> + <dangerous-button + :class="['ui', {loading: isLoading}, 'basic button']" + :action="remove"> + <translate translate-context="*/*/*/Verb">Delete</translate> + <p slot="modal-header"><translate translate-context="Popup/Library/Title">Delete this album?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The track will be removed, as well as associated uploads, favorites and listening history. 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">Track data</translate> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Title</translate> + </td> + <td> + {{ object.title }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.albums.detail', params: {id: object.album.id }}"> + <translate translate-context="*/*/*/Noun">Album</translate> + </router-link> + </td> + <td> + {{ object.album.title }} + </td> + </tr> + + <tr> + <td> + <router-link :to="{name: 'manage.library.artists.detail', params: {id: object.artist.id }}"> + <translate translate-context="*/*/*/Noun">Artist</translate> + </router-link> + </td> + <td> + {{ object.artist.name }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.artists.detail', params: {id: object.album.artist.id }}"> + <translate translate-context="*/*/*/Noun">Album artist</translate> + </router-link> + </td> + <td> + {{ object.album.artist.name }} + </td> + </tr> + <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">Position</translate> + </td> + <td> + {{ object.position }} + </td> + </tr> + <tr v-if="object.disc_number"> + <td> + <translate translate-context="*/*/*/Noun">Disc number</translate> + </td> + <td> + {{ object.disc_number }} + </td> + </tr> + <tr v-if="object.copyright"> + <td> + <translate translate-context="Content/Track/Table.Label/Noun">Copyright</translate> + </td> + <td>{{ object.copyright }}</td> + </tr> + <tr v-if="object.license"> + <td> + <translate translate-context="Content/*/*/Noun">License</translate> + </td> + <td> + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('license', object.license)}}"> + {{ object.license }} + </router-link> + </td> + </tr> + <tr v-if="!object.is_local"> + <td> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </td> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> + {{ object.domain }} + </router-link> + </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="*/*/*/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.library.edits', query: {q: getQuery('target', 'track ' + object.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> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </td> + <td> + {{ stats.libraries }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </td> + <td> + {{ stats.uploads }} + </td> + </tr> + </tbody> + </table> + + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" + + +export default { + props: ["id"], + 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/library/tracks/${this.id}/` + axios.get(url).then(response => { + self.object = response.data + self.isLoading = false + }) + }, + fetchStats() { + var self = this + this.isLoadingStats = true + let url = `manage/library/tracks/${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/library/tracks/${this.id}/` + axios.delete(url).then(response => { + self.$router.push({name: 'manage.library.tracks'}) + }) + }, + 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> diff --git a/front/src/views/admin/library/TracksList.vue b/front/src/views/admin/library/TracksList.vue new file mode 100644 index 0000000000..3aefc86060 --- /dev/null +++ b/front/src/views/admin/library/TracksList.vue @@ -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> + <tracks-table :update-url="true" :default-query="defaultQuery"></tracks-table> + </section> + </main> +</template> + +<script> +import TracksTable from "@/components/manage/library/TracksTable" + +export default { + components: { + TracksTable + }, + props: { + defaultQuery: {type: String, required: false}, + }, + computed: { + labels() { + return { + title: this.$pgettext('*/*/*', 'Tracks') + } + } + } +} +</script> diff --git a/front/src/views/admin/moderation/AccountsDetail.vue b/front/src/views/admin/moderation/AccountsDetail.vue index 2338d4812c..ec02f7e11a 100644 --- a/front/src/views/admin/moderation/AccountsDetail.vue +++ b/front/src/views/admin/moderation/AccountsDetail.vue @@ -14,7 +14,7 @@ {{ object.full_username }} <div class="sub header"> <template v-if="object.user"> - <span class="ui tiny teal icon label"> + <span class="ui tiny teal label"> <i class="home icon"></i> <translate translate-context="Content/Moderation/*/Short, Noun">Local account</translate> </span> -- GitLab