diff --git a/api/funkwhale_api/common/models.py b/api/funkwhale_api/common/models.py index 87f7dc8e3a4031b23ee6111c2ebcfb449234857b..52a02cad9433f4e79972b6cfe858534c830cb513 100644 --- a/api/funkwhale_api/common/models.py +++ b/api/funkwhale_api/common/models.py @@ -5,9 +5,10 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder -from django.db import models, transaction +from django.db import connections, models, transaction from django.db.models import Lookup from django.db.models.fields import Field +from django.db.models.sql.compiler import SQLCompiler from django.utils import timezone from django.urls import reverse @@ -25,6 +26,41 @@ class NotEqual(Lookup): return "%s <> %s" % (lhs, rhs), params +class NullsLastSQLCompiler(SQLCompiler): + def get_order_by(self): + result = super().get_order_by() + if result and self.connection.vendor == "postgresql": + return [ + ( + expr, + ( + sql + " NULLS LAST" if not sql.endswith(" NULLS LAST") else sql, + params, + is_ref, + ), + ) + for (expr, (sql, params, is_ref)) in result + ] + return result + + +class NullsLastQuery(models.sql.query.Query): + """Use a custom compiler to inject 'NULLS LAST' (for PostgreSQL).""" + + def get_compiler(self, using=None, connection=None): + if using is None and connection is None: + raise ValueError("Need either using or connection") + if using: + connection = connections[using] + return NullsLastSQLCompiler(self, connection, using) + + +class NullsLastQuerySet(models.QuerySet): + def __init__(self, model=None, query=None, using=None, hints=None): + super().__init__(model, query, using, hints) + self.query = query or NullsLastQuery(self.model) + + class LocalFromFidQuerySet: def local(self, include=True): host = settings.FEDERATION_HOSTNAME diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 7d3d5639de3520a2647b773885868c6ff24fa66a..3e3cb0e52b8c19909fca4914bdf5c80aedcb97b2 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -1,4 +1,5 @@ import tempfile +import urllib.parse import uuid from django.conf import settings @@ -43,6 +44,18 @@ class FederationMixin(models.Model): class Meta: abstract = True + @property + def is_local(self): + return federation_utils.is_local(self.fid) + + @property + def domain_name(self): + if not self.fid: + return + + parsed = urllib.parse.urlparse(self.fid) + return parsed.hostname + class ActorQuerySet(models.QuerySet): def local(self, include=True): diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 64a6473e0b93b471197511cb88ae82f2a5647c16..c6f5db53ade2a163955807bd904a07ac1fb0962c 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,4 +1,8 @@ from django import forms +from django.db.models import Q +from django.conf import settings + +import django_filters from django_filters import rest_framework as filters from funkwhale_api.common import fields @@ -11,19 +15,32 @@ from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models -class ManageUploadFilterSet(filters.FilterSet): - q = fields.SearchFilter( - search_fields=[ - "track__title", - "track__album__title", - "track__artist__name", - "source", - ] - ) +class ActorField(forms.CharField): + def clean(self, value): + value = super().clean(value) + if not value: + return value - class Meta: - model = music_models.Upload - fields = ["q", "track__album", "track__artist", "track"] + parts = value.split("@") + + return { + "username": parts[0], + "domain": parts[1] if len(parts) > 1 else settings.FEDERATION_HOSTNAME, + } + + +def get_actor_filter(actor_field): + def handler(v): + if not v: + return Q(**{actor_field: None}) + return Q( + **{ + "{}__preferred_username__iexact".format(actor_field): v["username"], + "{}__domain__name__iexact".format(actor_field): v["domain"], + } + ) + + return {"field": ActorField(), "handler": handler} class ManageArtistFilterSet(filters.FilterSet): @@ -37,7 +54,11 @@ class ManageArtistFilterSet(filters.FilterSet): filter_fields={ "domain": { "handler": lambda v: federation_utils.get_domain_query_from_url(v) - } + }, + "library_id": { + "to": "tracks__uploads__library_id", + "field": forms.IntegerField(), + }, }, ) ) @@ -61,6 +82,10 @@ class ManageAlbumFilterSet(filters.FilterSet): "domain": { "handler": lambda v: federation_utils.get_domain_query_from_url(v) }, + "library_id": { + "to": "tracks__uploads__library_id", + "field": forms.IntegerField(), + }, }, ) ) @@ -93,6 +118,10 @@ class ManageTrackFilterSet(filters.FilterSet): "domain": { "handler": lambda v: federation_utils.get_domain_query_from_url(v) }, + "library_id": { + "to": "uploads__library_id", + "field": forms.IntegerField(), + }, }, ) ) @@ -102,6 +131,96 @@ class ManageTrackFilterSet(filters.FilterSet): fields = ["q", "title", "mbid", "fid", "artist", "album", "license"] +class ManageLibraryFilterSet(filters.FilterSet): + ordering = django_filters.OrderingFilter( + # tuple-mapping retains order + fields=( + ("creation_date", "creation_date"), + ("_uploads_count", "uploads_count"), + ("followers_count", "followers_count"), + ) + ) + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "name": {"to": "name"}, + "description": {"to": "description"}, + "fid": {"to": "fid"}, + }, + filter_fields={ + "artist_id": { + "to": "uploads__track__artist_id", + "field": forms.IntegerField(), + }, + "album_id": { + "to": "uploads__track__album_id", + "field": forms.IntegerField(), + }, + "track_id": {"to": "uploads__track__id", "field": forms.IntegerField()}, + "domain": {"to": "actor__domain_id"}, + "account": get_actor_filter("actor"), + "privacy_level": {"to": "privacy_level"}, + }, + ) + ) + domain = filters.CharFilter("actor__domain_id") + + class Meta: + model = music_models.Library + fields = ["q", "name", "fid", "privacy_level", "domain"] + + +class ManageUploadFilterSet(filters.FilterSet): + ordering = django_filters.OrderingFilter( + # tuple-mapping retains order + fields=( + ("creation_date", "creation_date"), + ("modification_date", "modification_date"), + ("accessed_date", "accessed_date"), + ("size", "size"), + ("bitrate", "bitrate"), + ("duration", "duration"), + ) + ) + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "source": {"to": "source"}, + "fid": {"to": "fid"}, + "track": {"to": "track__title"}, + "album": {"to": "track__album__title"}, + "artist": {"to": "track__artist__name"}, + }, + filter_fields={ + "library_id": {"to": "library_id", "field": forms.IntegerField()}, + "artist_id": {"to": "track__artist_id", "field": forms.IntegerField()}, + "album_id": {"to": "track__album_id", "field": forms.IntegerField()}, + "track_id": {"to": "track__id", "field": forms.IntegerField()}, + "domain": {"to": "library__actor__domain_id"}, + "import_reference": {"to": "import_reference"}, + "type": {"to": "mimetype"}, + "status": {"to": "import_status"}, + "account": get_actor_filter("library__actor"), + "privacy_level": {"to": "library__privacy_level"}, + }, + ) + ) + domain = filters.CharFilter("library__actor__domain_id") + privacy_level = filters.CharFilter("library__privacy_level") + + class Meta: + model = music_models.Upload + fields = [ + "q", + "fid", + "privacy_level", + "domain", + "mimetype", + "import_reference", + "import_status", + ] + + class ManageDomainFilterSet(filters.FilterSet): q = fields.SearchFilter(search_fields=["name"]) diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index cf6a1eab4b698b4bed8e899b2f10470c0c5b5219..add9364e86a3fc9e7f82873fbbea15949a59fde4 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -15,67 +15,6 @@ from funkwhale_api.users import models as users_models from . import filters -class ManageUploadArtistSerializer(serializers.ModelSerializer): - class Meta: - model = music_models.Artist - fields = ["id", "mbid", "creation_date", "name"] - - -class ManageUploadAlbumSerializer(serializers.ModelSerializer): - artist = ManageUploadArtistSerializer() - - class Meta: - model = music_models.Album - fields = ( - "id", - "mbid", - "title", - "artist", - "release_date", - "cover", - "creation_date", - ) - - -class ManageUploadTrackSerializer(serializers.ModelSerializer): - artist = ManageUploadArtistSerializer() - album = ManageUploadAlbumSerializer() - - class Meta: - model = music_models.Track - fields = ("id", "mbid", "title", "album", "artist", "creation_date", "position") - - -class ManageUploadSerializer(serializers.ModelSerializer): - track = ManageUploadTrackSerializer() - - class Meta: - model = music_models.Upload - fields = ( - "id", - "path", - "source", - "filename", - "mimetype", - "track", - "duration", - "mimetype", - "creation_date", - "bitrate", - "size", - "path", - ) - - -class ManageUploadActionSerializer(common_serializers.ActionSerializer): - actions = [common_serializers.Action("delete", allow_all=False)] - filterset_class = filters.ManageUploadFilterSet - - @transaction.atomic - def handle_delete(self, objects): - return objects.delete() - - class PermissionsSerializer(serializers.Serializer): def to_representation(self, o): return o.get_permissions(defaults=self.context.get("default_permissions")) @@ -493,3 +432,111 @@ class ManageArtistActionSerializer(common_serializers.ActionSerializer): @transaction.atomic def handle_delete(self, objects): return objects.delete() + + +class ManageLibraryActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageLibraryFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + +class ManageUploadActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageUploadFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() + + +class ManageLibrarySerializer(serializers.ModelSerializer): + domain = serializers.CharField(source="domain_name") + actor = ManageBaseActorSerializer() + uploads_count = serializers.SerializerMethodField() + followers_count = serializers.SerializerMethodField() + + class Meta: + model = music_models.Library + fields = [ + "id", + "uuid", + "fid", + "url", + "name", + "description", + "domain", + "is_local", + "creation_date", + "privacy_level", + "uploads_count", + "followers_count", + "followers_url", + "actor", + ] + + def get_uploads_count(self, obj): + return getattr(obj, "_uploads_count", obj.uploads_count) + + def get_followers_count(self, obj): + return getattr(obj, "followers_count", None) + + +class ManageNestedLibrarySerializer(serializers.ModelSerializer): + domain = serializers.CharField(source="domain_name") + actor = ManageBaseActorSerializer() + + class Meta: + model = music_models.Library + fields = [ + "id", + "uuid", + "fid", + "url", + "name", + "description", + "domain", + "is_local", + "creation_date", + "privacy_level", + "followers_url", + "actor", + ] + + +class ManageUploadSerializer(serializers.ModelSerializer): + track = ManageNestedTrackSerializer() + library = ManageNestedLibrarySerializer() + domain = serializers.CharField(source="domain_name") + + class Meta: + model = music_models.Upload + fields = ( + "id", + "uuid", + "fid", + "domain", + "is_local", + "audio_file", + "listen_url", + "source", + "filename", + "mimetype", + "duration", + "mimetype", + "bitrate", + "size", + "creation_date", + "accessed_date", + "modification_date", + "metadata", + "import_date", + "import_details", + "import_status", + "import_metadata", + "import_reference", + "track", + "library", + ) diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index f93667725ae0e2ad4354b2527bcef3738b64c676..2d5da59e3ed6b8656c3438fb9121819e18079d47 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -7,10 +7,11 @@ federation_router = routers.SimpleRouter() 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"artists", views.ManageArtistViewSet, "artists") +library_router.register(r"libraries", views.ManageLibraryViewSet, "libraries") library_router.register(r"tracks", views.ManageTrackViewSet, "tracks") +library_router.register(r"uploads", views.ManageUploadViewSet, "uploads") moderation_router = routers.SimpleRouter() moderation_router.register( diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 6fc1a2f1e63512574d1d20bed54f2abd0429690f..48ed62a022ab367dc88d8db9ba51c7086fa5a46c 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -19,38 +19,6 @@ from funkwhale_api.users import models as users_models from . import filters, serializers -class ManageUploadViewSet( - mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet -): - queryset = ( - music_models.Upload.objects.all() - .select_related("track__artist", "track__album__artist") - .order_by("-id") - ) - serializer_class = serializers.ManageUploadSerializer - filterset_class = filters.ManageUploadFilterSet - required_scope = "instance:libraries" - ordering_fields = [ - "accessed_date", - "modification_date", - "creation_date", - "track__artist__name", - "bitrate", - "size", - "duration", - ] - - @rest_decorators.action(methods=["post"], detail=False) - def action(self, request, *args, **kwargs): - queryset = self.get_queryset() - serializer = serializers.ManageUploadActionSerializer( - request.data, queryset=queryset - ) - serializer.is_valid(raise_exception=True) - result = serializer.save() - return response.Response(result, status=200) - - def get_stats(tracks, target): data = {} tracks = list(tracks.values_list("pk", flat=True)) @@ -70,6 +38,12 @@ def get_stats(tracks, target): ).count() data["libraries"] = uploads.values_list("library", flat=True).distinct().count() data["uploads"] = uploads.count() + data.update(get_media_stats(uploads)) + return data + + +def get_media_stats(uploads): + data = {} 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 @@ -85,6 +59,7 @@ class ManageArtistViewSet( ): queryset = ( music_models.Artist.objects.all() + .distinct() .order_by("-id") .select_related("attributed_to") .prefetch_related( @@ -130,6 +105,7 @@ class ManageAlbumViewSet( ): queryset = ( music_models.Album.objects.all() + .distinct() .order_by("-id") .select_related("attributed_to", "artist") .prefetch_related("tracks") @@ -164,6 +140,7 @@ class ManageTrackViewSet( ): queryset = ( music_models.Track.objects.all() + .distinct() .order_by("-id") .select_related("attributed_to", "artist", "album__artist") .annotate(uploads_count=Count("uploads")) @@ -196,6 +173,96 @@ class ManageTrackViewSet( return response.Response(result, status=200) +class ManageLibraryViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = ( + music_models.Library.objects.all() + .distinct() + .order_by("-id") + .select_related("actor") + .annotate( + followers_count=Count("received_follows", distinct=True), + _uploads_count=Count("uploads", distinct=True), + ) + ) + serializer_class = serializers.ManageLibrarySerializer + filterset_class = filters.ManageLibraryFilterSet + required_scope = "instance:libraries" + + @rest_decorators.action(methods=["get"], detail=True) + def stats(self, request, *args, **kwargs): + library = self.get_object() + uploads = library.uploads.all() + tracks = uploads.values_list("track", flat=True).distinct() + albums = ( + music_models.Track.objects.filter(pk__in=tracks) + .values_list("album", flat=True) + .distinct() + ) + artists = set( + music_models.Album.objects.filter(pk__in=albums).values_list( + "artist", flat=True + ) + ) | set( + music_models.Track.objects.filter(pk__in=tracks).values_list( + "artist", flat=True + ) + ) + + data = { + "uploads": uploads.count(), + "followers": library.received_follows.count(), + "tracks": tracks.count(), + "albums": albums.count(), + "artists": len(artists), + } + data.update(get_media_stats(uploads.all())) + 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 ManageUploadViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_field = "uuid" + queryset = ( + music_models.Upload.objects.all() + .distinct() + .order_by("-id") + .select_related("library__actor", "track__artist", "track__album__artist") + ) + serializer_class = serializers.ManageUploadSerializer + filterset_class = filters.ManageUploadFilterSet + required_scope = "instance:libraries" + + @rest_decorators.action(methods=["post"], detail=False) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageTrackActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) + + class ManageUserViewSet( mixins.ListModelMixin, mixins.RetrieveModelMixin, diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py index 7ad88d45f442ca3570c4ceaec266086821a1ed96..4b004bf15876026bebcf99bc9a67c1ab217eafd5 100644 --- a/api/funkwhale_api/music/models.py +++ b/api/funkwhale_api/music/models.py @@ -649,7 +649,7 @@ class Track(APIModelMixin): return licenses.LICENSES_BY_ID.get(self.license_id) -class UploadQuerySet(models.QuerySet): +class UploadQuerySet(common_models.NullsLastQuerySet): def playable_by(self, actor, include=True): libraries = Library.objects.viewable_by(actor) @@ -746,6 +746,18 @@ class Upload(models.Model): objects = UploadQuerySet.as_manager() + @property + def is_local(self): + return federation_utils.is_local(self.fid) + + @property + def domain_name(self): + if not self.fid: + return + + parsed = urllib.parse.urlparse(self.fid) + return parsed.hostname + def download_audio_from_remote(self, actor): from funkwhale_api.common import session from funkwhale_api.federation import signing diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 336a87ce0cc46af663c1be350fcb0e7f86cb3df5..86ea5d406293b8686004e024e3edab29137a1817 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -440,8 +440,6 @@ class UploadViewSet( "artist__name", ) - fetches = federation_decorators.fetches_route() - def get_queryset(self): qs = super().get_queryset() return qs.filter(library__actor=self.request.user.actor) diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 64a26538f9d8cac0b87826e90cb08fef111825a5..65c75c2c331cc013761d3ee00c22a985e21c2b21 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -399,12 +399,73 @@ def test_manage_track_serializer(factories, now): assert s.data == expected +def test_manage_library_serializer(factories, now): + library = factories["music.Library"]() + setattr(library, "followers_count", 42) + setattr(library, "_uploads_count", 44) + expected = { + "id": library.id, + "fid": library.fid, + "url": library.url, + "uuid": str(library.uuid), + "followers_url": library.followers_url, + "domain": library.domain_name, + "is_local": library.is_local, + "name": library.name, + "description": library.description, + "privacy_level": library.privacy_level, + "creation_date": library.creation_date.isoformat().split("+")[0] + "Z", + "actor": serializers.ManageBaseActorSerializer(library.actor).data, + "uploads_count": 44, + "followers_count": 42, + } + s = serializers.ManageLibrarySerializer(library) + + assert s.data == expected + + +def test_manage_upload_serializer(factories, now): + upload = factories["music.Upload"]() + + expected = { + "id": upload.id, + "fid": upload.fid, + "audio_file": upload.audio_file.url, + "listen_url": upload.listen_url, + "uuid": str(upload.uuid), + "domain": upload.domain_name, + "is_local": upload.is_local, + "duration": upload.duration, + "size": upload.size, + "bitrate": upload.bitrate, + "mimetype": upload.mimetype, + "source": upload.source, + "filename": upload.filename, + "metadata": upload.metadata, + "creation_date": upload.creation_date.isoformat().split("+")[0] + "Z", + "modification_date": upload.modification_date.isoformat().split("+")[0] + "Z", + "accessed_date": None, + "import_date": None, + "import_metadata": upload.import_metadata, + "import_status": upload.import_status, + "import_reference": upload.import_reference, + "import_details": upload.import_details, + "library": serializers.ManageNestedLibrarySerializer(upload.library).data, + "track": serializers.ManageNestedTrackSerializer(upload.track).data, + } + s = serializers.ManageUploadSerializer(upload) + + assert s.data == expected + + @pytest.mark.parametrize( "factory, serializer_class", [ ("music.Track", serializers.ManageTrackActionSerializer), ("music.Album", serializers.ManageAlbumActionSerializer), ("music.Artist", serializers.ManageArtistActionSerializer), + ("music.Library", serializers.ManageLibraryActionSerializer), + ("music.Upload", serializers.ManageUploadActionSerializer), ], ) def test_action_serializer_delete(factory, serializer_class, factories): diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index 923d331d8ca722a38d31410be539097f1df87453..e3d136a0e841ab5e49c9dd782083d5f3b9c5cf2a 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -1,4 +1,3 @@ -import pytest from django.urls import reverse from funkwhale_api.federation import models as federation_models @@ -6,21 +5,6 @@ from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.manage import serializers -@pytest.mark.skip(reason="Refactoring in progress") -def test_upload_view(factories, superuser_api_client): - uploads = factories["music.Upload"].create_batch(size=5) - qs = uploads[0].__class__.objects.order_by("-creation_date") - url = reverse("api:v1:manage:library:uploads-list") - - response = superuser_api_client.get(url, {"sort": "-creation_date"}) - expected = serializers.ManageUploadSerializer( - qs, many=True, context={"request": response.wsgi_request} - ).data - - assert response.data["count"] == len(uploads) - assert response.data["results"] == expected - - def test_user_view(factories, superuser_api_client, mocker): mocker.patch("funkwhale_api.users.models.User.record_activity") users = factories["users.User"].create_batch(size=5) + [superuser_api_client.user] @@ -289,3 +273,82 @@ def test_track_delete(factories, superuser_api_client): response = superuser_api_client.delete(url) assert response.status_code == 204 + + +def test_library_list(factories, superuser_api_client, settings): + library = factories["music.Library"]() + url = reverse("api:v1:manage:library:libraries-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == library.id + + +def test_library_detail(factories, superuser_api_client): + library = factories["music.Library"]() + url = reverse( + "api:v1:manage:library:libraries-detail", kwargs={"uuid": library.uuid} + ) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == library.id + + +def test_library_detail_stats(factories, superuser_api_client): + library = factories["music.Library"]() + url = reverse( + "api:v1:manage:library:libraries-stats", kwargs={"uuid": library.uuid} + ) + response = superuser_api_client.get(url) + expected = { + "uploads": 0, + "followers": 0, + "tracks": 0, + "albums": 0, + "artists": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + assert response.status_code == 200 + assert response.data == expected + + +def test_library_delete(factories, superuser_api_client): + library = factories["music.Library"]() + url = reverse( + "api:v1:manage:library:libraries-detail", kwargs={"uuid": library.uuid} + ) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 + + +def test_upload_list(factories, superuser_api_client, settings): + upload = factories["music.Upload"]() + url = reverse("api:v1:manage:library:uploads-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == upload.id + + +def test_upload_detail(factories, superuser_api_client): + upload = factories["music.Upload"]() + url = reverse("api:v1:manage:library:uploads-detail", kwargs={"uuid": upload.uuid}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == upload.id + + +def test_upload_delete(factories, superuser_api_client): + upload = factories["music.Upload"]() + url = reverse("api:v1:manage:library:uploads-detail", kwargs={"uuid": upload.uuid}) + response = superuser_api_client.delete(url) + + assert response.status_code == 204 diff --git a/front/src/components/library/ImportStatusModal.vue b/front/src/components/library/ImportStatusModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..5632e95943cb78571ed55af3a458ab788e7bceca --- /dev/null +++ b/front/src/components/library/ImportStatusModal.vue @@ -0,0 +1,164 @@ +<template> + + <modal :show.sync="showModal"> + <div class="header"> + <translate translate-context="Popup/Import/Title">Import detail</translate> + </div> + <div class="content" v-if="upload"> + <div class="description"> + <div class="ui message" v-if="upload.import_status === 'pending'"> + <translate translate-context="Popup/Import/Message">Upload is still pending and will soon be processed by the server.</translate> + </div> + <div class="ui success message" v-if="upload.import_status === 'finished'"> + <translate translate-context="Popup/Import/Message">Upload was successfully processed by the server.</translate> + </div> + <div class="ui warning message" v-if="upload.import_status === 'skipped'"> + <translate translate-context="Popup/Import/Message">Upload was skipped because a similar one is already available in one of your libraries.</translate> + </div> + <div class="ui error message" v-if="upload.import_status === 'errored'"> + <translate translate-context="Popup/Import/Message">An error occured during upload processing. You will find more information below.</translate> + </div> + <template v-if="upload.import_status === 'errored'"> + <table class="ui very basic collapsing celled table"> + <tbody> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Error type</translate> + </td> + <td> + {{ getErrorData(upload).label }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Error detail</translate> + </td> + <td> + {{ getErrorData(upload).detail }} + <ul v-if="getErrorData(upload).errorRows.length > 0"> + <li v-for="row in getErrorData(upload).errorRows"> + {{ row.key}}: {{ row.value}} + </li> + </ul> + </td> + </tr> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Getting help</translate> + </td> + <td> + <ul> + <li> + <a :href="getErrorData(upload).documentationUrl" target="_blank"> + <translate translate-context="Popup/Import/Table.Label/Value">Read our documentation for this error</translate> + </a> + </li> + <li> + <a :href="getErrorData(upload).supportUrl" target="_blank"> + <translate translate-context="Popup/Import/Table.Label/Value">Open a support thread (include the debug information below in your message)</translate> + </a> + </li> + </ul> + </td> + </tr> + <tr> + <td> + <translate translate-context="Popup/Import/Table.Label/Noun">Debug information</translate> + </td> + <td> + <div class="ui form"> + <textarea class="ui textarea" rows="10" :value="getErrorData(upload).debugInfo"></textarea> + </div> + </td> + </tr> + </tbody> + </table> + </template> + </div> + </div> + <div class="actions"> + <div class="ui deny button"> + <translate translate-context="*/*/Button.Label/Verb">Close</translate> + </div> + </div> + </modal> +</template> +<script> +import Modal from '@/components/semantic/Modal' + +function getErrors(payload) { + let errors = [] + for (var k in payload) { + if (payload.hasOwnProperty(k)) { + let value = payload[k] + if (Array.isArray(value)) { + errors.push({ + key: k, + value: value.join(', ') + }) + } else { + // possibly artists, so nested errors + if (typeof value === 'object') { + getErrors(value).forEach((e) => { + errors.push({ + key: `${k} / ${e.key}`, + value: e.value + }) + }) + } + } + } + } + return errors +} + +export default { + props: ['upload', "show"], + components: { + Modal + }, + data () { + return { + showModal: this.show + } + }, + methods: { + getErrorData (upload) { + let payload = upload.import_details || {} + let d = { + supportUrl: 'https://governance.funkwhale.audio/g/246YOJ1m/funkwhale-support', + errorRows: [] + } + if (!payload.error_code) { + d.errorCode = 'unknown_error' + } else { + d.errorCode = payload.error_code + } + d.documentationUrl = `https://docs.funkwhale.audio/users/upload.html#${d.errorCode}` + if (d.errorCode === 'invalid_metadata') { + d.label = this.$pgettext('Popup/Import/Error.Label', 'Invalid metadata') + d.detail = this.$pgettext('Popup/Import/Error.Label', 'The metadata included in the file is invalid or some mandatory fields are missing.') + let detail = payload.detail || {} + d.errorRows = getErrors(detail) + } else { + d.label = this.$pgettext('Popup/Import/Error.Label', 'Unkwown error') + d.detail = this.$pgettext('Popup/Import/Error.Label', 'An unkwown error occured') + } + let debugInfo = { + source: upload.source, + ...payload, + } + d.debugInfo = JSON.stringify(debugInfo, null, 4) + return d + } + }, + watch: { + showModal (v) { + this.$emit('update:show', v) + }, + show (v) { + this.showModal = v + } + } +} +</script> diff --git a/front/src/components/manage/library/LibrariesTable.vue b/front/src/components/manage/library/LibrariesTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..88c58f3110ff068b9905a81d9bd09deffc1e7bda --- /dev/null +++ b/front/src/components/manage/library/LibrariesTable.vue @@ -0,0 +1,235 @@ +<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="*/*/*">Visibility</translate></label> + <select class="ui dropdown" @change="addSearchToken('privacy_level', $event.target.value)" :value="getTokenValue('privacy_level', '')"> + <option value=""><translate translate-context="Content/*/Dropdown">All</translate></option> + <option value="me">{{ sharedLabels.fields.privacy_level.shortChoices.me }}</option> + <option value="instance">{{ sharedLabels.fields.privacy_level.shortChoices.instance }}</option> + <option value="everyone">{{ sharedLabels.fields.privacy_level.shortChoices.everyone }}</option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ sharedLabels.filters[option[1]] }} + </option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> + <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + action-url="manage/library/libraries/action/" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate translate-context="*/*/*">Name</translate></th> + <th><translate translate-context="*/*/*">Account</translate></th> + <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> + <th><translate translate-context="*/*/*">Visibility</translate></th> + <th><translate translate-context="Content/*/*/Noun">Uploads</translate></th> + <th><translate translate-context="Content/*/*/Noun">Followers</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.libraries.detail', params: {id: scope.obj.uuid }}">{{ scope.obj.name }}</router-link> + </td> + <td> + <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.actor.full_username }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('account', scope.obj.actor.full_username)" :title="scope.obj.actor.full_username">{{ scope.obj.actor.preferred_username }}</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" + class="discrete link" + @click="addSearchToken('privacy_level', scope.obj.privacy_level)" + :title="sharedLabels.fields.privacy_level.shortChoices[scope.obj.privacy_level]"> + {{ sharedLabels.fields.privacy_level.shortChoices[scope.obj.privacy_level] }} + </span> + </td> + <td> + {{ scope.obj.uploads_count }} + </td> + <td> + {{ scope.obj.followers_count }} + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.count > paginateBy" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + <translate translate-context="Content/*/Paragraph" + :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}"> + Showing results %{ start }-%{ end } on %{ total } + </translate> + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from '@/lodash' +import time from '@/utils/time' +import {normalizeQuery, parseTokens} from '@/search' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' +import TranslationsMixin from '@/components/mixins/Translations' +import SmartSearchMixin from '@/components/mixins/SmartSearch' + + +export default { + mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], + props: { + filters: {type: Object, required: false}, + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: { + query: this.defaultQuery, + tokens: parseTokens(normalizeQuery(this.defaultQuery)) + }, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'creation_date'], + ['followers_count', 'followers'], + ['uploads_count', 'uploads'], + ] + } + }, + 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/libraries/', {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, actor, name, description…') + } + }, + 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 library will be removed, as well as associated uploads and follows. This action is irreversible.') + return [ + { + name: 'delete', + label: deleteLabel, + confirmationMessage: confirmationMessage, + isDangerous: true, + allowAll: false, + confirmColor: 'red', + }, + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/manage/library/UploadsTable.vue b/front/src/components/manage/library/UploadsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..efc4e23944387bf84ae8330452a561d341e90dfe --- /dev/null +++ b/front/src/components/manage/library/UploadsTable.vue @@ -0,0 +1,285 @@ +<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="*/*/*">Visibility</translate></label> + <select class="ui dropdown" @change="addSearchToken('privacy_level', $event.target.value)" :value="getTokenValue('privacy_level', '')"> + <option value=""><translate translate-context="Content/*/Dropdown">All</translate></option> + <option value="me">{{ sharedLabels.fields.privacy_level.shortChoices.me }}</option> + <option value="instance">{{ sharedLabels.fields.privacy_level.shortChoices.instance }}</option> + <option value="everyone">{{ sharedLabels.fields.privacy_level.shortChoices.everyone }}</option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Library/*/Noun">Import status</translate></label> + <select class="ui dropdown" @change="addSearchToken('status', $event.target.value)" :value="getTokenValue('status', '')"> + <option value=""><translate translate-context="Content/*/Dropdown">All</translate></option> + <option value="pending"><translate translate-context="Content/Library/*/Short">Pending</translate></option> + <option value="skipped"><translate translate-context="Content/Library/*">Skipped</translate></option> + <option value="errored"><translate translate-context="Content/Library/Dropdown">Failed</translate></option> + <option value="finished"><translate translate-context="Content/Library/*">Finished</translate></option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ sharedLabels.filters[option[1]] }} + </option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> + <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option> + </select> + </div> + </div> + </div> + <import-status-modal :upload="detailedUpload" :show.sync="showUploadDetailModal" /> + <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/uploads/action/" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate translate-context="*/*/*">Name</translate></th> + <th><translate translate-context="*/*/*">Library</translate></th> + <th><translate translate-context="*/*/*">Account</translate></th> + <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> + <th><translate translate-context="*/*/*">Visibility</translate></th> + <th><translate translate-context="Content/*/*/Noun">Import status</translate></th> + <th><translate translate-context="Content/*/*/Noun">Size</translate></th> + <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th> + <th><translate translate-context="Content/*/*/Noun">Accessed date</translate></th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.library.uploads.detail', params: {id: scope.obj.uuid }}" :title="displayName(scope.obj)"> + {{ displayName(scope.obj)|truncate(30, "…", true) }} + </router-link> + </td> + <td> + <router-link :to="{name: 'manage.library.libraries.detail', params: {id: scope.obj.library.uuid }}"> + <i class="wrench icon"></i> + </router-link> + <span role="button" class="discrete link" + @click="addSearchToken('library_id', scope.obj.library.id)" + :title="scope.obj.library.name"> + {{ scope.obj.library.name | truncate(20) }} + </span> + </td> + <td> + <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.library.actor.full_username }}"> + </router-link> + <span role="button" class="discrete link" @click="addSearchToken('account', scope.obj.library.actor.full_username)" :title="scope.obj.library.actor.full_username">{{ scope.obj.library.actor.preferred_username }}</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" + class="discrete link" + @click="addSearchToken('privacy_level', scope.obj.library.privacy_level)" + :title="sharedLabels.fields.privacy_level.shortChoices[scope.obj.library.privacy_level]"> + {{ sharedLabels.fields.privacy_level.shortChoices[scope.obj.library.privacy_level] }} + </span> + </td> + <td> + <span class="discrete link" @click="addSearchToken('status', scope.obj.import_status)" :title="sharedLabels.fields.import_status.choices[scope.obj.import_status].help"> + {{ sharedLabels.fields.import_status.choices[scope.obj.import_status].label }} + </span> + <button class="ui tiny basic icon button" :title="sharedLabels.fields.import_status.detailTitle" @click="detailedUpload = scope.obj; showUploadDetailModal = true"> + <i class="question circle outline icon"></i> + </button> + </td> + <td> + <span v-if="scope.obj.size">{{ scope.obj.size | humanSize }}</span> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td> + <human-date v-if="scope.obj.accessed_date" :date="scope.obj.accessed_date"></human-date> + <translate v-else translate-context="*/*/*">N/A</translate> + </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' +import ImportStatusModal from '@/components/library/ImportStatusModal' + + +export default { + mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], + props: { + filters: {type: Object, required: false}, + }, + components: { + Pagination, + ActionTable, + ImportStatusModal + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + detailedUpload: null, + showUploadDetailModal: false, + 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'], + ['modification_date', 'modification_date'], + ['accessed_date', 'accessed_date'], + ['size', 'size'], + ['bitrate', 'bitrate'], + ['duration', 'duration'], + ] + } + }, + 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/uploads/', {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 + }, + displayName (upload) { + if (upload.filename) { + return upload.filename + } + if (upload.source) { + return upload.source + } + return upload.uuid + } + }, + computed: { + labels () { + return { + searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by domain, actor, name, reference, source…') + } + }, + 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 upload will be removed. This action is irreversible.') + return [ + { + name: 'delete', + label: deleteLabel, + confirmationMessage: confirmationMessage, + isDangerous: true, + allowAll: false, + confirmColor: 'red', + }, + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index b2bd455cc64e916a11f36a361a0573db3a88d5e1..56ea3ed152642d6b5fa99e415d42625262571224 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -11,12 +11,39 @@ export default { me: this.$pgettext('Content/Settings/Dropdown', 'Nobody except me'), instance: this.$pgettext('Content/Settings/Dropdown', 'Everyone on this instance'), everyone: this.$pgettext('Content/Settings/Dropdown', 'Everyone, across all instances'), + }, + shortChoices: { + me: this.$pgettext('Content/Settings/Dropdown/Short', 'Private'), + instance: this.$pgettext('Content/Settings/Dropdown/Short', 'Instance'), + everyone: this.$pgettext('Content/Settings/Dropdown/Short', 'Everyone'), } - } + }, + import_status: { + detailTitle: this.$pgettext('Content/Library/Link.Title', 'Click to display more information about the import process for this upload'), + choices: { + skipped: { + label: this.$pgettext('Content/Library/*', 'Skipped'), + help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries'), + }, + pending: { + label: this.$pgettext('Content/Library/*/Short', 'Pending'), + help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet'), + }, + errored: { + label: this.$pgettext('Content/Library/Table/Short', 'Errored'), + help: this.$pgettext('Content/Library/Help text', 'This track could not be processed, please it is tagged correctly'), + }, + finished: { + label: this.$pgettext('Content/Library/*', 'Finished'), + help: this.$pgettext('Content/Library/Help text', 'Imported'), + }, + } + }, }, filters: { creation_date: this.$pgettext('Content/*/*/Noun', 'Creation date'), release_date: this.$pgettext('Content/*/*/Noun', 'Release date'), + accessed_date: this.$pgettext('Content/*/*/Noun', 'Accessed date'), first_seen: this.$pgettext('Content/Moderation/Dropdown/Noun', 'First seen date'), last_seen: this.$pgettext('Content/Moderation/Dropdown/Noun', 'Last seen date'), modification_date: this.$pgettext('Content/Playlist/Dropdown/Noun', 'Modification date'), diff --git a/front/src/filters.js b/front/src/filters.js index 1edea76f6afb6b8f929b7ab0566b15d4f1813e75..9667426191e6fae0aa76ba9eba47dcad023730fe 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -2,13 +2,24 @@ import Vue from 'vue' import moment from 'moment' -export function truncate (str, max, ellipsis) { +export function truncate (str, max, ellipsis, middle) { max = max || 100 ellipsis = ellipsis || '…' if (str.length <= max) { return str } - return str.slice(0, max) + ellipsis + if (middle) { + var sepLen = 1, + charsToShow = max - sepLen, + frontChars = Math.ceil(charsToShow/2), + backChars = Math.floor(charsToShow/2); + + return str.substr(0, frontChars) + + ellipsis + + str.substr(str.length - backChars); + } else { + return str.slice(0, max) + ellipsis + } } Vue.filter('truncate', truncate) diff --git a/front/src/router/index.js b/front/src/router/index.js index f9332f5f556683067ae0fe03b187a2826d6e0e41..4b59deacc2ab731045b2eacb5dfef49095bd4bf9 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -43,6 +43,10 @@ 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 AdminLibraryLibrariesList from '@/views/admin/library/LibrariesList' +import AdminLibraryLibraryDetail from '@/views/admin/library/LibraryDetail' +import AdminLibraryUploadsList from '@/views/admin/library/UploadsList' +import AdminLibraryUploadDetail from '@/views/admin/library/UploadDetail' import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersList from '@/views/admin/users/UsersList' import AdminInvitationsList from '@/views/admin/users/InvitationsList' @@ -303,6 +307,38 @@ export default new Router({ component: AdminLibraryTrackDetail, props: true }, + { + path: 'libraries', + name: 'manage.library.libraries', + component: AdminLibraryLibrariesList, + props: (route) => { + return { + defaultQuery: route.query.q, + } + } + }, + { + path: 'libraries/:id', + name: 'manage.library.libraries.detail', + component: AdminLibraryLibraryDetail, + props: true + }, + { + path: 'uploads', + name: 'manage.library.uploads', + component: AdminLibraryUploadsList, + props: (route) => { + return { + defaultQuery: route.query.q, + } + } + }, + { + path: 'uploads/:id', + name: 'manage.library.uploads.detail', + component: AdminLibraryUploadDetail, + props: true + }, ] }, { diff --git a/front/src/views/admin/library/AlbumDetail.vue b/front/src/views/admin/library/AlbumDetail.vue index b5d802d98591e32dbde7148271535286000470c1..8de907a72bcb8d07671156e27939cc2195028c6c 100644 --- a/front/src/views/admin/library/AlbumDetail.vue +++ b/front/src/views/admin/library/AlbumDetail.vue @@ -4,7 +4,7 @@ <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="object"> - <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name"> + <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.title"> <div class="ui stackable one column grid"> <div class="ui column"> <div class="segment-content"> @@ -113,23 +113,15 @@ {{ 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 }} + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> </router-link> </td> + <td> + {{ object.domain }} + </td> </tr> </tbody> </table> @@ -153,6 +145,14 @@ </div> <table v-else class="ui very basic table"> <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> <tr> <td> <translate translate-context="*/*/*/Noun">Listenings</translate> @@ -229,7 +229,9 @@ <tr> <td> - <translate translate-context="*/*/*/Noun">Libraries</translate> + <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('album_id', object.id) }}"> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </router-link> </td> <td> {{ stats.libraries }} @@ -237,7 +239,9 @@ </tr> <tr> <td> - <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('album_id', object.id) }}"> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </router-link> </td> <td> {{ stats.uploads }} diff --git a/front/src/views/admin/library/ArtistDetail.vue b/front/src/views/admin/library/ArtistDetail.vue index d509f7394ad516492e17f4bd625b5e78866b5001..91b3542b2dc1c93721f4036699f6f84aa90643cb 100644 --- a/front/src/views/admin/library/ArtistDetail.vue +++ b/front/src/views/admin/library/ArtistDetail.vue @@ -102,23 +102,15 @@ {{ 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 }} + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> </router-link> </td> + <td> + {{ object.domain }} + </td> </tr> </tbody> </table> @@ -142,6 +134,14 @@ </div> <table v-else class="ui very basic table"> <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> <tr> <td> <translate translate-context="*/*/*/Noun">Listenings</translate> @@ -218,7 +218,9 @@ <tr> <td> - <translate translate-context="*/*/*/Noun">Libraries</translate> + <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('artist_id', object.id) }}"> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </router-link> </td> <td> {{ stats.libraries }} @@ -226,7 +228,9 @@ </tr> <tr> <td> - <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('artist_id', object.id) }}"> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </router-link> </td> <td> {{ stats.uploads }} diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 4b7048a17bbe0cd6d187d49f1a6e0e606be05a9c..009e1ca95c9f8bc615c73c37f808887b14949711 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -13,6 +13,12 @@ <router-link class="ui item" :to="{name: 'manage.library.tracks'}"><translate translate-context="*/*/*">Tracks</translate></router-link> + <router-link + class="ui item" + :to="{name: 'manage.library.libraries'}"><translate translate-context="*/*/*">Libraries</translate></router-link> + <router-link + class="ui item" + :to="{name: 'manage.library.uploads'}"><translate translate-context="*/*/*">Uploads</translate></router-link> </nav> <router-view :key="$route.fullPath"></router-view> </div> diff --git a/front/src/views/admin/library/LibrariesList.vue b/front/src/views/admin/library/LibrariesList.vue new file mode 100644 index 0000000000000000000000000000000000000000..495a660c19609fde101fff87b2774f6ca7890c3c --- /dev/null +++ b/front/src/views/admin/library/LibrariesList.vue @@ -0,0 +1,29 @@ +<template> + <main v-title="labels.title"> + <section class="ui vertical stripe segment"> + <h2 class="ui header">{{ labels.title }}</h2> + <div class="ui hidden divider"></div> + <libraries-table :update-url="true" :default-query="defaultQuery"></libraries-table> + </section> + </main> +</template> + +<script> +import LibrariesTable from "@/components/manage/library/LibrariesTable" + +export default { + components: { + LibrariesTable + }, + props: { + defaultQuery: {type: String, required: false}, + }, + computed: { + labels() { + return { + title: this.$pgettext('*/*/*', 'Libraries') + } + } + } +} +</script> diff --git a/front/src/views/admin/library/LibraryDetail.vue b/front/src/views/admin/library/LibraryDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..a4df048da668db2f77ef304052204d09d69f4bf1 --- /dev/null +++ b/front/src/views/admin/library/LibraryDetail.vue @@ -0,0 +1,321 @@ +<template> + <main> + <div v-if="isLoading" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <template v-if="object"> + <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name"> + <div class="ui stackable one column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <i class="circular inverted book icon"></i> + <div class="content"> + {{ object.name | truncate(100) }} + <div class="sub header"> + <template v-if="object.is_local"> + <span class="ui tiny teal label"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + + </template> + </div> + </div> + </h2> + <div class="header-buttons"> + + <div class="ui icon buttons"> + <a + v-if="$store.state.auth.profile.is_superuser" + class="ui labeled icon button" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/library/${object.id}`)" + target="_blank" rel="noopener noreferrer"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> + </a> + <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/library/${object.id}`)" + target="_blank" rel="noopener noreferrer"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> + </a> + <a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer"> + <i class="external icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate> + </a> + </div> + </div> + </div> + <div class="ui buttons"> + <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 library?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The library will be removed, as well as associated uploads, and follows. 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">Library 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> + <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('privacy_level', object.privacy_level) }}"> + <translate translate-context="*/*/*">Visibility</translate> + </router-link> + </td> + <td> + {{ sharedLabels.fields.privacy_level.shortChoices[object.privacy_level] }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: object.actor.full_username }}"> + <translate translate-context="*/*/*/Noun">Account</translate> + </router-link> + </td> + <td> + {{ object.actor.preferred_username }} + </td> + </tr> + <tr v-if="!object.is_local"> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </router-link> + </td> + <td> + {{ object.domain }} + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Description</translate> + </td> + <td> + {{ object.description }} + </td> + </tr> + </tbody> + </table> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="feed icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Activity</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <div v-if="isLoadingStats" class="ui placeholder"> + <div class="full line"></div> + <div class="short line"></div> + <div class="medium line"></div> + <div class="long line"></div> + </div> + <table v-else class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Federation/*/Noun">Followers</translate> + </td> + <td> + {{ stats.followers }} + </td> + </tr> + </tbody> + </table> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="music icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Audio content</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <div v-if="isLoadingStats" class="ui placeholder"> + <div class="full line"></div> + <div class="short line"></div> + <div class="medium line"></div> + <div class="long line"></div> + </div> + <table v-else class="ui very basic table"> + <tbody> + + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Noun">Cached size</translate> + </td> + <td> + {{ stats.media_downloaded_size | humanSize }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label">Total size</translate> + </td> + <td> + {{ stats.media_total_size | humanSize }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.artists', query: {q: getQuery('library_id', object.id) }}"> + <translate translate-context="*/*/*">Artists</translate> + </router-link> + </td> + <td> + {{ stats.artists }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.albums', query: {q: getQuery('library_id', object.id) }}"> + <translate translate-context="*/*/*">Albums</translate> + </router-link> + </td> + <td> + {{ stats.albums }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('library_id', object.id) }}"> + <translate translate-context="*/*/*">Tracks</translate> + </router-link> + </td> + <td> + {{ stats.tracks }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('library_id', object.id) }}"> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </router-link> + </td> + <td> + {{ stats.uploads }} + </td> + </tr> + </tbody> + </table> + + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" +import TranslationsMixin from "@/components/mixins/Translations" + + +export default { + props: ["id"], + mixins: [ + TranslationsMixin + ], + 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/libraries/${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/libraries/${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/libraries/${this.id}/` + axios.delete(url).then(response => { + self.$router.push({name: 'manage.library.libraries'}) + }) + }, + getQuery (field, value) { + return `${field}:"${value}"` + } + }, + computed: { + labels() { + return { + statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object'), + } + }, + } +} +</script> diff --git a/front/src/views/admin/library/TrackDetail.vue b/front/src/views/admin/library/TrackDetail.vue index 15e08f7a8f169537e9eb373446e66f251385d420..3256d0d639a88a65839186c627533c5c8bd5d499 100644 --- a/front/src/views/admin/library/TrackDetail.vue +++ b/front/src/views/admin/library/TrackDetail.vue @@ -4,7 +4,7 @@ <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="object"> - <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name"> + <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.title"> <div class="ui stackable one column grid"> <div class="ui column"> <div class="segment-content"> @@ -133,14 +133,6 @@ {{ 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> @@ -174,14 +166,14 @@ </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 }} + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> </router-link> </td> + <td> + {{ object.domain }} + </td> </tr> </tbody> </table> @@ -205,6 +197,14 @@ </div> <table v-else class="ui very basic table"> <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> <tr> <td> <translate translate-context="*/*/*/Noun">Listenings</translate> @@ -281,7 +281,9 @@ <tr> <td> - <translate translate-context="*/*/*/Noun">Libraries</translate> + <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('track_id', object.id) }}"> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </router-link> </td> <td> {{ stats.libraries }} @@ -289,7 +291,9 @@ </tr> <tr> <td> - <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('track_id', object.id) }}"> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </router-link> </td> <td> {{ stats.uploads }} diff --git a/front/src/views/admin/library/UploadDetail.vue b/front/src/views/admin/library/UploadDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..604d0af3046312605a06e5bf0e1e0b6b400d5954 --- /dev/null +++ b/front/src/views/admin/library/UploadDetail.vue @@ -0,0 +1,340 @@ +<template> + <main> + <div v-if="isLoading" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <template v-if="object"> + <import-status-modal :upload="object" :show.sync="showUploadDetailModal" /> + <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="displayName(object)"> + <div class="ui stackable one column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <i class="circular inverted file icon"></i> + <div class="content"> + {{ displayName(object) | truncate(100) }} + <div class="sub header"> + <template v-if="object.is_local"> + <span class="ui tiny teal label"> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </span> + + </template> + </div> + </div> + </h2> + <div class="header-buttons"> + + <div class="ui icon buttons"> + <a + v-if="$store.state.auth.profile.is_superuser" + class="ui labeled icon button" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/upload/${object.id}`)" + target="_blank" rel="noopener noreferrer"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> + </a> + <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/upload/${object.id}`)" + target="_blank" rel="noopener noreferrer"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> + </a> + <a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer"> + <i class="external icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate> + </a> + </div> + </div> + </div> + <div class="ui buttons"> + <a class="ui labeled icon button" v-if="object.audio_file" :href="$store.getters['instance/absoluteUrl'](object.audio_file)" target="_blank" rel="noopener noreferrer"> + <i class="download icon"></i> + <translate translate-context="Content/Track/Link/Verb">Download</translate> + </a> + </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 upload?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The upload will be removed. 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">Upload data</translate> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Name</translate> + </td> + <td> + {{ displayName(object) }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('privacy_level', object.library.privacy_level) }}"> + <translate translate-context="*/*/*">Visibility</translate> + </router-link> + </td> + <td> + {{ sharedLabels.fields.privacy_level.shortChoices[object.library.privacy_level] }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: object.library.actor.full_username }}"> + <translate translate-context="*/*/*/Noun">Account</translate> + </router-link> + </td> + <td> + {{ object.library.actor.preferred_username }} + </td> + </tr> + <tr v-if="!object.is_local"> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}"> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </router-link> + </td> + <td> + {{ object.domain }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('status', object.import_status) }}"> + <translate translate-context="Content/*/*/Noun">Import status</translate> + </router-link> + </td> + <td> + {{ sharedLabels.fields.import_status.choices[object.import_status].label }} + <button class="ui tiny basic icon button" :title="sharedLabels.fields.import_status.detailTitle" @click="detailedUpload = object; showUploadDetailModal = true"> + <i class="question circle outline icon"></i> + </button> + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.libraries.detail', params: {id: object.library.uuid }}"> + <translate translate-context="*/*/*">Library</translate> + </router-link> + </td> + <td> + {{ object.library.name }} + </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> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/*/*/Noun">Accessed date</translate> + </td> + <td> + <human-date v-if="object.accessed_date" :date="object.accessed_date"></human-date> + <translate v-else translate-context="*/*/*">N/A</translate> + </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> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr v-if="object.track"> + <td> + <router-link :to="{name: 'manage.library.tracks.detail', params: {id: object.track.id }}"> + <translate translate-context="*/*/*">Track</translate> + </router-link> + </td> + <td> + {{ object.track.title }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Noun">Cached size</translate> + </td> + <td> + <template v-if="object.audio_file"> + {{ object.size | humanSize }} + </template> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/*/*/Noun">Size</translate> + </td> + <td> + {{ object.size | humanSize }} + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Track/*/Noun">Bitrate</translate> + </td> + <td> + <template v-if="object.bitrate"> + {{ object.bitrate | humanSize }}/s + </template> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/*/*">Duration</translate> + </td> + <td> + <template v-if="object.duration"> + {{ time.parse(object.duration) }} + </template> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('type', object.mimetype) }}"> + <translate translate-context="Content/Track/Table.Label/Noun">Type</translate> + </router-link> + </td> + <td> + <template v-if="object.mimetype"> + {{ object.mimetype }} + </template> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + </tr> + </tbody> + </table> + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" +import TranslationsMixin from "@/components/mixins/Translations" +import ImportStatusModal from '@/components/library/ImportStatusModal' +import time from '@/utils/time' + + +export default { + props: ["id"], + mixins: [ + TranslationsMixin, + ], + components: { + ImportStatusModal + }, + data() { + return { + time, + detailedUpload: null, + showUploadDetailModal: false, + isLoading: true, + object: null, + stats: null, + } + }, + created() { + this.fetchData() + }, + methods: { + fetchData() { + var self = this + this.isLoading = true + let url = `manage/library/uploads/${this.id}/` + axios.get(url).then(response => { + self.object = response.data + self.isLoading = false + }) + }, + remove () { + var self = this + this.isLoading = true + let url = `manage/library/uploads/${this.id}/` + axios.delete(url).then(response => { + self.$router.push({name: 'manage.library.uploads'}) + }) + }, + getQuery (field, value) { + return `${field}:"${value}"` + }, + displayName (upload) { + if (upload.filename) { + return upload.filename + } + if (upload.source) { + return upload.source + } + return upload.uuid + } + }, + computed: { + labels() { + return { + statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object'), + } + }, + } +} +</script> diff --git a/front/src/views/admin/library/UploadsList.vue b/front/src/views/admin/library/UploadsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..0d4d7b5e3c2975914ebbb05f3a5d3e26e044cd6a --- /dev/null +++ b/front/src/views/admin/library/UploadsList.vue @@ -0,0 +1,29 @@ +<template> + <main v-title="labels.title"> + <section class="ui vertical stripe segment"> + <h2 class="ui header">{{ labels.title }}</h2> + <div class="ui hidden divider"></div> + <uploads-table :update-url="true" :default-query="defaultQuery"></uploads-table> + </section> + </main> +</template> + +<script> +import UploadsTable from "@/components/manage/library/UploadsTable" + +export default { + components: { + UploadsTable + }, + props: { + defaultQuery: {type: String, required: false}, + }, + computed: { + labels() { + return { + title: this.$pgettext('*/*/*', 'Uploads') + } + } + } +} +</script> diff --git a/front/src/views/admin/moderation/AccountsDetail.vue b/front/src/views/admin/moderation/AccountsDetail.vue index ec02f7e11ac0b87bbf302e3085d23cc0eec6fd79..d863af52d7432ea82e34d157fb23c90b6d03bb9b 100644 --- a/front/src/views/admin/moderation/AccountsDetail.vue +++ b/front/src/views/admin/moderation/AccountsDetail.vue @@ -90,14 +90,14 @@ </td> </tr> <tr v-if="!object.user"> - <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 }} + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> </router-link> </td> + <td> + {{ object.domain }} + </td> </tr> <tr> <td> @@ -155,14 +155,6 @@ {{ object.type }} </td> </tr> - <tr v-if="!object.user"> - <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.user"> <td> <translate translate-context="Content/*/Table.Label">Last checked</translate> @@ -210,6 +202,14 @@ </div> <table v-else class="ui very basic table"> <tbody> + <tr v-if="!object.user"> + <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="Content/Moderation/Table.Label/Noun">Emitted messages</translate> @@ -295,7 +295,9 @@ <tr> <td> - <translate translate-context="*/*/*/Noun">Libraries</translate> + <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('account', object.full_username) }}"> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </router-link> </td> <td> {{ stats.libraries }} @@ -303,7 +305,9 @@ </tr> <tr> <td> - <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('account', object.full_username) }}"> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </router-link> </td> <td> {{ stats.uploads }} @@ -446,6 +450,9 @@ export default { ) } ) + }, + getQuery (field, value) { + return `${field}:"${value}"` } }, computed: { diff --git a/front/src/views/admin/moderation/DomainsDetail.vue b/front/src/views/admin/moderation/DomainsDetail.vue index e0e2b5680c2e0e7fa62218873bd2f3f38ad93ce1..575b15f93ca2bbf696b9672cbb38e7535c8b09ab 100644 --- a/front/src/views/admin/moderation/DomainsDetail.vue +++ b/front/src/views/admin/moderation/DomainsDetail.vue @@ -74,14 +74,6 @@ </h3> <table class="ui very basic table"> <tbody> - <tr> - <td> - <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> - </td> - <td> - <human-date :date="object.creation_date"></human-date> - </td> - </tr> <tr> <td> <translate translate-context="Content/*/Table.Label">Last checked</translate> @@ -155,6 +147,14 @@ </div> <table v-else class="ui very basic table"> <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> <tr> <td> <router-link @@ -231,7 +231,9 @@ </tr> <tr> <td> - <translate translate-context="*/*/*/Noun">Libraries</translate> + <router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('domain', object.name) }}"> + <translate translate-context="*/*/*/Noun">Libraries</translate> + </router-link> </td> <td> {{ stats.libraries }} @@ -239,7 +241,9 @@ </tr> <tr> <td> - <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + <router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('domain', object.name) }}"> + <translate translate-context="Content/Moderation/Table.Label/Noun">Uploads</translate> + </router-link> </td> <td> {{ stats.uploads }} @@ -247,7 +251,9 @@ </tr> <tr> <td> - <translate translate-context="*/*/*/Noun">Artists</translate> + <router-link :to="{name: 'manage.library.artists', query: {q: getQuery('domain', object.name) }}"> + <translate translate-context="*/*/*/Noun">Artists</translate> + </router-link> </td> <td> {{ stats.artists }} @@ -255,7 +261,9 @@ </tr> <tr> <td> - <translate translate-context="*/*/*">Albums</translate> + <router-link :to="{name: 'manage.library.albums', query: {q: getQuery('domain', object.name) }}"> + <translate translate-context="*/*/*">Albums</translate> + </router-link> </td> <td> {{ stats.albums}} @@ -263,7 +271,9 @@ </tr> <tr> <td> - <translate translate-context="*/*/*/Noun">Tracks</translate> + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('domain', object.name) }}"> + <translate translate-context="*/*/*/Noun">Tracks</translate> + </router-link> </td> <td> {{ stats.tracks }} @@ -350,6 +360,9 @@ export default { updatePolicy (policy) { this.policy = policy this.showPolicyForm = false + }, + getQuery (field, value) { + return `${field}:"${value}"` } }, computed: { diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index f6c4e69efaa9a90c96db8aa4d82bbd3bfe3d9c9a..9ea4634adcb6360dbc5e8c454405b6deeac11527 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -35,88 +35,7 @@ </div> </div> </div> - <modal :show.sync="showUploadDetailModal"> - <div class="header"> - <translate translate-context="Popup/Import/Title">Import detail</translate> - </div> - <div class="content" v-if="detailedUpload"> - <div class="description"> - <div class="ui message" v-if="detailedUpload.import_status === 'pending'"> - <translate translate-context="Popup/Import/Message">Upload is still pending and will soon be processed by the server.</translate> - </div> - <div class="ui success message" v-if="detailedUpload.import_status === 'finished'"> - <translate translate-context="Popup/Import/Message">Upload was successfully processed by the server.</translate> - </div> - <div class="ui warning message" v-if="detailedUpload.import_status === 'skipped'"> - <translate translate-context="Popup/Import/Message">Upload was skipped because a similar one is already available in one of your libraries.</translate> - </div> - <div class="ui error message" v-if="detailedUpload.import_status === 'errored'"> - <translate translate-context="Popup/Import/Message">An error occured during upload processing. You will find more information below.</translate> - </div> - <template v-if="detailedUpload.import_status === 'errored'"> - <table class="ui very basic collapsing celled table"> - <tbody> - <tr> - <td> - <translate translate-context="Popup/Import/Table.Label/Noun">Error type</translate> - </td> - <td> - {{ getErrorData(detailedUpload).label }} - </td> - </tr> - <tr> - <td> - <translate translate-context="Popup/Import/Table.Label/Noun">Error detail</translate> - </td> - <td> - {{ getErrorData(detailedUpload).detail }} - <ul v-if="getErrorData(detailedUpload).errorRows.length > 0"> - <li v-for="row in getErrorData(detailedUpload).errorRows"> - {{ row.key}}: {{ row.value}} - </li> - </ul> - </td> - </tr> - <tr> - <td> - <translate translate-context="Popup/Import/Table.Label/Noun">Getting help</translate> - </td> - <td> - <ul> - <li> - <a :href="getErrorData(detailedUpload).documentationUrl" target="_blank"> - <translate translate-context="Popup/Import/Table.Label/Value">Read our documentation for this error</translate> - </a> - </li> - <li> - <a :href="getErrorData(detailedUpload).supportUrl" target="_blank"> - <translate translate-context="Popup/Import/Table.Label/Value">Open a support thread (include the debug information below in your message)</translate> - </a> - </li> - </ul> - </td> - </tr> - <tr> - <td> - <translate translate-context="Popup/Import/Table.Label/Noun">Debug information</translate> - </td> - <td> - <div class="ui form"> - <textarea class="ui textarea" rows="10" :value="getErrorData(detailedUpload).debugInfo"></textarea> - </div> - </td> - </tr> - </tbody> - </table> - </template> - </div> - </div> - <div class="actions"> - <div class="ui deny button"> - <translate translate-context="*/*/Button.Label/Verb">Close</translate> - </div> - </div> - </modal> + <import-status-modal :upload="detailedUpload" :show.sync="showUploadDetailModal" /> <div class="dimmable"> <div v-if="isLoading" class="ui active inverted dimmer"> <div class="ui loader"></div> @@ -163,10 +82,10 @@ <human-date :date="scope.obj.creation_date"></human-date> </td> <td> - <span class="discrete link" @click="addSearchToken('status', scope.obj.import_status)" :title="labels.importStatuses[scope.obj.import_status].help"> - {{ labels.importStatuses[scope.obj.import_status].label }} + <span class="discrete link" @click="addSearchToken('status', scope.obj.import_status)" :title="sharedLabels.fields.import_status.choices[scope.obj.import_status].help"> + {{ sharedLabels.fields.import_status.choices[scope.obj.import_status].label }} </span> - <button class="ui tiny basic icon button" :title="labels.statusDetailTitle" @click="detailedUpload = scope.obj; showUploadDetailModal = true"> + <button class="ui tiny basic icon button" :title="sharedLabels.fields.import_status.detailTitle" @click="detailedUpload = scope.obj; showUploadDetailModal = true"> <i class="question circle outline icon"></i> </button> </td> @@ -216,33 +135,8 @@ import ActionTable from '@/components/common/ActionTable' import OrderingMixin from '@/components/mixins/Ordering' import TranslationsMixin from '@/components/mixins/Translations' import SmartSearchMixin from '@/components/mixins/SmartSearch' -import Modal from '@/components/semantic/Modal' +import ImportStatusModal from '@/components/library/ImportStatusModal' -function getErrors(payload) { - let errors = [] - for (var k in payload) { - if (payload.hasOwnProperty(k)) { - let value = payload[k] - if (Array.isArray(value)) { - errors.push({ - key: k, - value: value.join(', ') - }) - } else { - // possibly artists, so nested errors - if (typeof value === 'object') { - getErrors(value).forEach((e) => { - errors.push({ - key: `${k} / ${e.key}`, - value: e.value - }) - }) - } - } - } - } - return errors -} export default { mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], props: { @@ -253,7 +147,7 @@ export default { components: { Pagination, ActionTable, - Modal + ImportStatusModal }, data () { return { @@ -307,58 +201,11 @@ export default { selectPage: function (page) { this.page = page }, - getErrorData (upload) { - let payload = upload.import_details || {} - let d = { - supportUrl: 'https://governance.funkwhale.audio/g/246YOJ1m/funkwhale-support', - errorRows: [] - } - if (!payload.error_code) { - d.errorCode = 'unknown_error' - } else { - d.errorCode = payload.error_code - } - d.documentationUrl = `https://docs.funkwhale.audio/users/upload.html#${d.errorCode}` - if (d.errorCode === 'invalid_metadata') { - d.label = this.$pgettext('Popup/Import/Error.Label', 'Invalid metadata') - d.detail = this.$pgettext('Popup/Import/Error.Label', 'The metadata included in the file is invalid or some mandatory fields are missing.') - let detail = payload.detail || {} - d.errorRows = getErrors(detail) - } else { - d.label = this.$pgettext('Popup/Import/Error.Label', 'Unkwown error') - d.detail = this.$pgettext('Popup/Import/Error.Label', 'An unkwown error occured') - } - let debugInfo = { - source: upload.source, - ...payload, - } - d.debugInfo = JSON.stringify(debugInfo, null, 4) - return d - } }, computed: { labels () { return { searchPlaceholder: this.$pgettext('Content/Library/Input.Placeholder', 'Search by title, artist, album…'), - statusDetailTitle: this.$pgettext('Content/Library/Link.Title', 'Click to display more information about the import process for this upload'), - importStatuses: { - skipped: { - label: this.$pgettext('Content/Library/*', 'Skipped'), - help: this.$pgettext('Content/Library/Help text', 'This track is already present in one of your libraries'), - }, - pending: { - label: this.$pgettext('Content/Library/*/Short', 'Pending'), - help: this.$pgettext('Content/Library/Help text', 'This track has been uploaded, but hasn\'t been processed by the server yet'), - }, - errored: { - label: this.$pgettext('Content/Library/Table/Short', 'Errored'), - help: this.$pgettext('Content/Library/Help text', 'This track could not be processed, please it is tagged correctly'), - }, - finished: { - label: this.$pgettext('Content/Library/*', 'Finished'), - help: this.$pgettext('Content/Library/Help text', 'Imported'), - }, - } } }, actionFilters () {