Merge branch '689-library-upload-admin' into 'develop'

Admin UI for libraries and uploads

See merge request funkwhale/funkwhale!724
This commit is contained in:
Eliot Berriot 2019-04-19 12:05:13 +02:00
commit 2a377ede7b
27 changed files with 2140 additions and 361 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,22 +113,14 @@
{{ 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>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</router-link>
</td>
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
{{ object.domain }}
</router-link>
{{ object.domain }}
</td>
</tr>
</tbody>
@ -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 }}

View File

@ -102,22 +102,14 @@
{{ 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>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</router-link>
</td>
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
{{ object.domain }}
</router-link>
{{ object.domain }}
</td>
</tr>
</tbody>
@ -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 }}

View File

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

View File

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

View File

@ -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>
&nbsp;
</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>&nbsp;
</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>&nbsp;
</a>
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
<i class="external icon"></i>
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>&nbsp;
</a>
</div>
</div>
</div>
<div class="ui buttons">
<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>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr>
<td>
<translate translate-context="Content/Moderation/Table.Label/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>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr>
<td>
<translate translate-context="Content/Moderation/Table.Label/Noun">Cached size</translate>
</td>
<td>
{{ stats.media_downloaded_size | humanSize }}
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/Moderation/Table.Label">Total size</translate>
</td>
<td>
{{ stats.media_total_size | humanSize }}
</td>
</tr>
<tr>
<td>
<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>

View File

@ -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>
@ -175,12 +167,12 @@
</tr>
<tr v-if="!object.is_local">
<td>
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</router-link>
</td>
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
{{ object.domain }}
</router-link>
{{ object.domain }}
</td>
</tr>
</tbody>
@ -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 }}

View File

@ -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>
&nbsp;
</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>&nbsp;
</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>&nbsp;
</a>
<a class="basic item" :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
<i class="external icon"></i>
<translate translate-context="Content/Moderation/Link/Verb">Open remote profile</translate>&nbsp;
</a>
</div>
</div>
</div>
<div class="ui buttons">
<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>&nbsp;
</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>&nbsp;
</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>

View File

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

View File

@ -91,12 +91,12 @@
</tr>
<tr v-if="!object.user">
<td>
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
<translate translate-context="Content/Moderation/*/Noun">Domain</translate>
</router-link>
</td>
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
{{ object.domain }}
</router-link>
{{ object.domain }}
</td>
</tr>
<tr>
@ -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: {

View File

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

View File

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