UI To manage artists, albums, tracks

merge-requests/757/head
Eliot Berriot 2019-04-17 14:17:59 +02:00
rodzic ae390e5c1c
commit b4731928fc
39 zmienionych plików z 2837 dodań i 116 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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"},
},
)

Wyświetl plik

@ -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:

Wyświetl plik

@ -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")

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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()

Wyświetl plik

@ -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(

Wyświetl plik

@ -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,

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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"

Wyświetl plik

@ -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(

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -29,7 +29,7 @@ export default {
}
</script>
<style>
<style scoped>
.ui.small.placeholder.segment {
min-height: auto;
}

Wyświetl plik

@ -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>&nbsp;
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<template v-if="discs && discs.length > 1">

Wyświetl plik

@ -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>&nbsp;
</a>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="ui small text container" v-if="contentFilter">

Wyświetl plik

@ -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>&nbsp;
</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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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

Wyświetl plik

@ -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'),

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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
},
]
},
{

Wyświetl plik

@ -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 {

Wyświetl plik

@ -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>
&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
<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>&nbsp;
<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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>
&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
<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>&nbsp;
<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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>
&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
</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>&nbsp;
<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>&nbsp;
<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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>