diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
index 47e673cb5b567cfebfc8ad14ef6aae07acf44687..b8e217ba4eca9ef0954236124a577a88cf4e72d5 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 4825d3b5d8d4a934d458faead03baa693f178b94..364a1fba18a276cef05a34976a95749408132dc9 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 622cb29dd174d1c7dbc3ece052b6e8ab5a5068a4..cc046f758d5d37ffc62d960517654f35dd099e27 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 743c95095b1f1042b3e411fe6cb784a18e5d1dd6..db39c56d1afbb5eed7fa0ba7c89693fb32c57ab4 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 3523396dbceb86a2aa8c84c1767cad9b6f767db1..8a8a1eb2de2059a2b6edeeec629c81147c84381b 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 a32256921a55f8eee1a5c406255d224b433eb5b0..2bbfdf7fadd023c3349961237f877677e29dc06a 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 edae49f991bcb9f2a415f8f829df1a1e7d7c5248..64a6473e0b93b471197511cb88ae82f2a5647c16 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 ed50d86777d30c7d8fc255944f5034f70a3f4bef..cf6a1eab4b698b4bed8e899b2f10470c0c5b5219 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 4c220fe0ef28598eab3feec59c9fb19ecf33bd86..f93667725ae0e2ad4354b2527bcef3738b64c676 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 588e66c589ba2adafd229e894fdf64536960c70f..6fc1a2f1e63512574d1d20bed54f2abd0429690f 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 9e0268504dc815bc24b37edb4d653a974d3af1e6..4b166be7c3e01b2909375997a5fe5322edd918a6 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 9a03fb429284f7955222a006deb7d055501d25e9..d2b53b41f620ac80caa5d5835ef3afdb02c1bd60 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 c6e70b6178cd35bf8ad3752f0c8aa1204de20e7f..4cbf4293a3c3a82bda4fb42d3936dc8b41ea0e8c 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 aef8dc4ea9c837124401733ead8dcbb64ae06aec..64a26538f9d8cac0b87826e90cb08fef111825a5 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 673c39cbc9458df3aaca10e1dea391ab3780524a..923d331d8ca722a38d31410be539097f1df87453 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 4446de7dd978a33686c699dae6e18f0248640437..5aa29b3cc4519d0c5158301d521a3550e2d56e62 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 aaac8430b5ae704e2de4d945fe07537188c14b8c..e73d3a3f91c71b30c2444719ed035f5049d3d4a2 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 213ab92ce9092cbe073422ce02386396fdd942ad..8b183b215875b532c40faeff53ed547d1eb766ef 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 9bbf18f85fbdf83d3c946bcbe3b5579381cf6cb2..1a43a5f9ff1ba4ea1e87ff61992458e8c947f0b1 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 cc9f32ca1ef413fba27c62646ac90dabf3756911..862700679e2a5e9db60d756d26f6081fdb7d9b7a 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 0031aeed8460801bccb77c3545a21abcee77d16b..1a5f6b50da9e05dc8e1d0b93ba5190909b492fb4 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 34071e746dde4617308d26b3cb9e646287fa34b8..4d6e10e2d755a924b294a85f1bdb49c340d17e46 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 cd4572f5b65990f8449f1881738eb9fd8b622786..6df2f7aa0caabde16847f7deb8f8aa2faedbc722 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 0000000000000000000000000000000000000000..3af2da4db9bccd1d5d691f2b0ee9152fe9d2b7e1
--- /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 0000000000000000000000000000000000000000..84c873832d5f3429565b4fd55caec6e03c1501bf
--- /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 0000000000000000000000000000000000000000..a702d8e25cb180827e1134aad530f99936bd7675
--- /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 8b03becbbd8e7fb7b3bd38eaa673d9b01615a728..ef450603961a8cbdf61385d861698993a7ee5734 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 2bbc64c83f05314018ce9e0ea101920a228cfc28..b2bd455cc64e916a11f36a361a0573db3a88d5e1 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 31aa07d03193354d3bd04daf960023b69c651307..959b776b3df147f1fab98c48cc2e74c7bdb05f67 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 8703eedead7693332d242ea6a52ef65c06aa358a..bee09f20281da8c94c6543c07ba63820bfa4f67b 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 cd12dda2f24465c1daabaa49b58379a623e79853..c1ddb3628d93e0111fcf19f99b35acb1eca70914 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 0000000000000000000000000000000000000000..3215da10113c8c3945e0ea132c111dea04c7ff99
--- /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 0000000000000000000000000000000000000000..650b4d69389aadd05ae8fd2e7a385ed02c2a5945
--- /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 0000000000000000000000000000000000000000..bfbd414a171900fe11a709f4a5624aaa1e5abd00
--- /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 0000000000000000000000000000000000000000..2a5932796ec6003703942ee1fe6e7e31f4d532c9
--- /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 b521e8f6ece9232d9a8c92221de0e7d5af8ef2ac..4b7048a17bbe0cd6d187d49f1a6e0e606be05a9c 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 0000000000000000000000000000000000000000..6e0c9f65e29b73ee89552a6b773324759a934a7c
--- /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 0000000000000000000000000000000000000000..3aefc86060af1a12aa006136f49c224f27b2b7af
--- /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 2338d4812c6e6ae461a2faaf6d11656b6fe2d8e2..ec02f7e11ac0b87bbf302e3085d23cc0eec6fd79 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>