Merge branch '689-library-admin' into 'develop'
UI To manage artists, albums, tracks See merge request funkwhale/funkwhale!715
This commit is contained in:
commit
2836b11190
|
@ -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)
|
||||
|
|
|
@ -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"},
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -79,24 +79,6 @@
|
|||
<div class="item" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']">
|
||||
<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']"
|
||||
class="item"
|
||||
: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['moderation']"
|
||||
class="item"
|
||||
:to="{name: 'manage.moderation.domains.list'}">
|
||||
<i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['library']"
|
||||
class="item"
|
||||
|
@ -108,6 +90,24 @@
|
|||
:class="['ui', 'teal', 'label']">
|
||||
{{ $store.state.ui.notifications.pendingReviewEdits }}</div>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['moderation']"
|
||||
class="item"
|
||||
:to="{name: 'manage.moderation.domains.list'}">
|
||||
<i class="shield icon"></i><translate translate-context="*/Moderation/*">Moderation</translate>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="$store.state.auth.availablePermissions['settings']"
|
||||
class="item"
|
||||
: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>
|
||||
</nav>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -29,7 +29,7 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.ui.small.placeholder.segment {
|
||||
min-height: auto;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue