From a0ae9bbb702f7626312ceea97f376631bd03065f Mon Sep 17 00:00:00 2001 From: Georg Krause Date: Thu, 16 Nov 2023 09:10:05 +0000 Subject: [PATCH] feat(api): Add NodeInfo 2.1 Part-of: --- api/config/urls/api_v2.py | 2 +- api/funkwhale_api/instance/serializers.py | 112 +++++++++++++++++----- api/funkwhale_api/instance/stats.py | 35 ++++++- api/funkwhale_api/instance/urls.py | 2 +- api/funkwhale_api/instance/urls_v2.py | 7 ++ api/funkwhale_api/instance/views.py | 57 ++++++++++- api/tests/instance/test_nodeinfo.py | 72 +++++++++++++- api/tests/test_urls.py | 4 +- changes/changelog.d/2085.feature | 1 + 9 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 api/funkwhale_api/instance/urls_v2.py create mode 100644 changes/changelog.d/2085.feature diff --git a/api/config/urls/api_v2.py b/api/config/urls/api_v2.py index d5e040337..a5fda5c7c 100644 --- a/api/config/urls/api_v2.py +++ b/api/config/urls/api_v2.py @@ -8,7 +8,7 @@ v2_patterns = router.urls v2_patterns += [ url( r"^instance/", - include(("funkwhale_api.instance.urls", "instance"), namespace="instance"), + include(("funkwhale_api.instance.urls_v2", "instance"), namespace="instance"), ), url( r"^radios/", diff --git a/api/funkwhale_api/instance/serializers.py b/api/funkwhale_api/instance/serializers.py index 15eeaed59..87dc1b139 100644 --- a/api/funkwhale_api/instance/serializers.py +++ b/api/funkwhale_api/instance/serializers.py @@ -12,6 +12,17 @@ class SoftwareSerializer(serializers.Serializer): return "funkwhale" +class SoftwareSerializer_v2(SoftwareSerializer): + repository = serializers.SerializerMethodField() + homepage = serializers.SerializerMethodField() + + def get_repository(self, obj): + return "https://dev.funkwhale.audio/funkwhale/funkwhale" + + def get_homepage(self, obj): + return "https://funkwhale.audio" + + class ServicesSerializer(serializers.Serializer): inbound = serializers.ListField(child=serializers.CharField(), default=[]) outbound = serializers.ListField(child=serializers.CharField(), default=[]) @@ -31,6 +42,8 @@ class UsersUsageSerializer(serializers.Serializer): class UsageSerializer(serializers.Serializer): users = UsersUsageSerializer() + localPosts = serializers.IntegerField(required=False) + localComments = serializers.IntegerField(required=False) class TotalCountSerializer(serializers.Serializer): @@ -92,19 +105,14 @@ class MetadataSerializer(serializers.Serializer): private = serializers.SerializerMethodField() shortDescription = serializers.SerializerMethodField() longDescription = serializers.SerializerMethodField() - rules = serializers.SerializerMethodField() contactEmail = serializers.SerializerMethodField() - terms = serializers.SerializerMethodField() nodeName = serializers.SerializerMethodField() banner = serializers.SerializerMethodField() defaultUploadQuota = serializers.SerializerMethodField() - library = serializers.SerializerMethodField() supportedUploadExtensions = serializers.ListField(child=serializers.CharField()) allowList = serializers.SerializerMethodField() - reportTypes = ReportTypeSerializer(source="report_types", many=True) funkwhaleSupportMessageEnabled = serializers.SerializerMethodField() instanceSupportMessage = serializers.SerializerMethodField() - endpoints = EndpointsSerializer() usage = MetadataUsageSerializer(source="stats", required=False) def get_private(self, obj) -> bool: @@ -116,15 +124,9 @@ class MetadataSerializer(serializers.Serializer): def get_longDescription(self, obj) -> str: return obj["preferences"].get("instance__long_description") - def get_rules(self, obj) -> str: - return obj["preferences"].get("instance__rules") - def get_contactEmail(self, obj) -> str: return obj["preferences"].get("instance__contact_email") - def get_terms(self, obj) -> str: - return obj["preferences"].get("instance__terms") - def get_nodeName(self, obj) -> str: return obj["preferences"].get("instance__name") @@ -137,15 +139,6 @@ class MetadataSerializer(serializers.Serializer): def get_defaultUploadQuota(self, obj) -> int: return obj["preferences"].get("users__upload_quota") - @extend_schema_field(NodeInfoLibrarySerializer) - def get_library(self, obj): - data = obj["stats"] or {} - data["federationEnabled"] = obj["preferences"].get("federation__enabled") - data["anonymousCanListen"] = not obj["preferences"].get( - "common__api_authentication_required" - ) - return NodeInfoLibrarySerializer(data).data - @extend_schema_field(AllowListStatSerializer) def get_allowList(self, obj): return AllowListStatSerializer( @@ -166,6 +159,54 @@ class MetadataSerializer(serializers.Serializer): return MetadataUsageSerializer(obj["stats"]).data +class Metadata20Serializer(MetadataSerializer): + library = serializers.SerializerMethodField() + reportTypes = ReportTypeSerializer(source="report_types", many=True) + endpoints = EndpointsSerializer() + rules = serializers.SerializerMethodField() + terms = serializers.SerializerMethodField() + + def get_rules(self, obj) -> str: + return obj["preferences"].get("instance__rules") + + def get_terms(self, obj) -> str: + return obj["preferences"].get("instance__terms") + + @extend_schema_field(NodeInfoLibrarySerializer) + def get_library(self, obj): + data = obj["stats"] or {} + data["federationEnabled"] = obj["preferences"].get("federation__enabled") + data["anonymousCanListen"] = not obj["preferences"].get( + "common__api_authentication_required" + ) + return NodeInfoLibrarySerializer(data).data + + +class MetadataContentLocalSerializer(serializers.Serializer): + artists = serializers.IntegerField() + releases = serializers.IntegerField() + recordings = serializers.IntegerField() + hoursOfContent = serializers.IntegerField() + + +class MetadataContentCategorySerializer(serializers.Serializer): + name = serializers.CharField() + count = serializers.IntegerField() + + +class MetadataContentSerializer(serializers.Serializer): + local = MetadataContentLocalSerializer() + topMusicCategories = MetadataContentCategorySerializer(many=True) + topPodcastCategories = MetadataContentCategorySerializer(many=True) + + +class Metadata21Serializer(MetadataSerializer): + languages = serializers.ListField(child=serializers.CharField()) + location = serializers.CharField() + content = MetadataContentSerializer() + features = serializers.ListField(child=serializers.CharField()) + + class NodeInfo20Serializer(serializers.Serializer): version = serializers.SerializerMethodField() software = SoftwareSerializer() @@ -196,9 +237,36 @@ class NodeInfo20Serializer(serializers.Serializer): usage = {"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0}} return UsageSerializer(usage).data - @extend_schema_field(MetadataSerializer) + @extend_schema_field(Metadata20Serializer) def get_metadata(self, obj): - return MetadataSerializer(obj).data + return Metadata20Serializer(obj).data + + +class NodeInfo21Serializer(NodeInfo20Serializer): + version = serializers.SerializerMethodField() + software = SoftwareSerializer_v2() + + def get_version(self, obj) -> str: + return "2.1" + + @extend_schema_field(UsageSerializer) + def get_usage(self, obj): + usage = None + if obj["preferences"]["instance__nodeinfo_stats_enabled"]: + usage = obj["stats"] + usage["localPosts"] = 0 + usage["localComments"] = 0 + else: + usage = { + "users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0}, + "localPosts": 0, + "localComments": 0, + } + return UsageSerializer(usage).data + + @extend_schema_field(Metadata21Serializer) + def get_metadata(self, obj): + return Metadata21Serializer(obj).data class SpaManifestIconSerializer(serializers.Serializer): diff --git a/api/funkwhale_api/instance/stats.py b/api/funkwhale_api/instance/stats.py index 923a1dadb..9353eb53e 100644 --- a/api/funkwhale_api/instance/stats.py +++ b/api/funkwhale_api/instance/stats.py @@ -1,6 +1,6 @@ import datetime -from django.db.models import Sum +from django.db.models import Count, F, Sum from django.utils import timezone from funkwhale_api.favorites.models import TrackFavorite @@ -22,6 +22,39 @@ def get(): } +def get_content(): + return { + "local": { + "artists": get_artists(), + "releases": get_albums(), + "recordings": get_tracks(), + "hoursOfContent": get_music_duration(), + }, + "topMusicCategories": get_top_music_categories(), + "topPodcastCategories": get_top_podcast_categories(), + } + + +def get_top_music_categories(): + return ( + models.Track.objects.filter(artist__content_category="music") + .exclude(tagged_items__tag_id=None) + .values(name=F("tagged_items__tag__name")) + .annotate(count=Count("name")) + .order_by("-count")[:3] + ) + + +def get_top_podcast_categories(): + return ( + models.Track.objects.filter(artist__content_category="podcast") + .exclude(tagged_items__tag_id=None) + .values(name=F("tagged_items__tag__name")) + .annotate(count=Count("name")) + .order_by("-count")[:3] + ) + + def get_users(): qs = User.objects.filter(is_active=True) now = timezone.now() diff --git a/api/funkwhale_api/instance/urls.py b/api/funkwhale_api/instance/urls.py index 6047eca19..62baa5021 100644 --- a/api/funkwhale_api/instance/urls.py +++ b/api/funkwhale_api/instance/urls.py @@ -8,7 +8,7 @@ admin_router = routers.OptionalSlashRouter() admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings") urlpatterns = [ - url(r"^nodeinfo/2.0/?$", views.NodeInfo.as_view(), name="nodeinfo-2.0"), + url(r"^nodeinfo/2.0/?$", views.NodeInfo20.as_view(), name="nodeinfo-2.0"), url(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"), url(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"), ] + admin_router.urls diff --git a/api/funkwhale_api/instance/urls_v2.py b/api/funkwhale_api/instance/urls_v2.py new file mode 100644 index 000000000..2b8ddcb3b --- /dev/null +++ b/api/funkwhale_api/instance/urls_v2.py @@ -0,0 +1,7 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r"^nodeinfo/2.1/?$", views.NodeInfo21.as_view(), name="nodeinfo-2.1"), +] diff --git a/api/funkwhale_api/instance/views.py b/api/funkwhale_api/instance/views.py index 0368bdd56..3272a95d8 100644 --- a/api/funkwhale_api/instance/views.py +++ b/api/funkwhale_api/instance/views.py @@ -59,7 +59,7 @@ class InstanceSettings(generics.GenericAPIView): @method_decorator(ensure_csrf_cookie, name="dispatch") -class NodeInfo(views.APIView): +class NodeInfo20(views.APIView): permission_classes = [] authentication_classes = [] serializer_class = serializers.NodeInfo20Serializer @@ -122,6 +122,61 @@ class NodeInfo(views.APIView): ) +class NodeInfo21(NodeInfo20): + serializer_class = serializers.NodeInfo21Serializer + + @extend_schema( + responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20" + ) + def get(self, request): + pref = preferences.all() + if ( + pref["moderation__allow_list_public"] + and pref["moderation__allow_list_enabled"] + ): + allowed_domains = list( + Domain.objects.filter(allowed=True) + .order_by("name") + .values_list("name", flat=True) + ) + else: + allowed_domains = None + + data = { + "software": {"version": funkwhale_version}, + "services": {"inbound": ["atom1.0"], "outbound": ["atom1.0"]}, + "preferences": pref, + "stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)() + if pref["instance__nodeinfo_stats_enabled"] + else None, + "actorId": get_service_actor().fid, + "supportedUploadExtensions": SUPPORTED_EXTENSIONS, + "allowed_domains": allowed_domains, + "languages": pref.get("moderation__languages"), + "location": pref.get("instance__location"), + "content": cache_memoize(600, prefix="memoize:instance:content")( + stats.get_content + )() + if pref["instance__nodeinfo_stats_enabled"] + else None, + "features": [ + "channels", + "podcasts", + ], + } + + if not pref.get("common__api_authentication_required"): + data["features"].append("anonymousCanListen") + + if pref.get("federation__enabled"): + data["features"].append("federation") + + serializer = self.serializer_class(data) + return Response( + serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE + ) + + PWA_MANIFEST_PATH = Path(__file__).parent / "pwa-manifest.json" PWA_MANIFEST: dict = json.loads(PWA_MANIFEST_PATH.read_text(encoding="utf-8")) diff --git a/api/tests/instance/test_nodeinfo.py b/api/tests/instance/test_nodeinfo.py index a58e5cc80..4f31aee2a 100644 --- a/api/tests/instance/test_nodeinfo.py +++ b/api/tests/instance/test_nodeinfo.py @@ -6,7 +6,7 @@ from funkwhale_api import __version__ as api_version from funkwhale_api.music.utils import SUPPORTED_EXTENSIONS -def test_nodeinfo_default(api_client): +def test_nodeinfo_20(api_client): url = reverse("api:v1:instance:nodeinfo-2.0") response = api_client.get(url) @@ -89,3 +89,73 @@ def test_nodeinfo_default(api_client): } assert response.data == expected + + +def test_nodeinfo_21(api_client): + url = reverse("api:v2:instance:nodeinfo-2.1") + response = api_client.get(url) + + expected = { + "version": "2.1", + "software": OrderedDict( + [ + ("name", "funkwhale"), + ("version", api_version), + ("repository", "https://dev.funkwhale.audio/funkwhale/funkwhale"), + ("homepage", "https://funkwhale.audio"), + ] + ), + "protocols": ["activitypub"], + "services": OrderedDict([("inbound", ["atom1.0"]), ("outbound", ["atom1.0"])]), + "openRegistrations": False, + "usage": { + "users": OrderedDict( + [("total", 0), ("activeHalfyear", 0), ("activeMonth", 0)] + ), + "localPosts": 0, + "localComments": 0, + }, + "metadata": { + "actorId": "https://test.federation/federation/actors/service", + "private": False, + "shortDescription": "", + "longDescription": "", + "contactEmail": "", + "nodeName": "", + "banner": None, + "defaultUploadQuota": 1000, + "supportedUploadExtensions": SUPPORTED_EXTENSIONS, + "allowList": {"enabled": False, "domains": None}, + "funkwhaleSupportMessageEnabled": True, + "instanceSupportMessage": "", + "usage": OrderedDict( + [ + ("favorites", OrderedDict([("tracks", {"total": 0})])), + ("listenings", OrderedDict([("total", 0)])), + ("downloads", OrderedDict([("total", 0)])), + ] + ), + "location": "", + "languages": ["en"], + "features": ["channels", "podcasts", "federation"], + "content": OrderedDict( + [ + ( + "local", + OrderedDict( + [ + ("artists", 0), + ("releases", 0), + ("recordings", 0), + ("hoursOfContent", 0), + ] + ), + ), + ("topMusicCategories", []), + ("topPodcastCategories", []), + ] + ), + }, + } + + assert response.data == expected diff --git a/api/tests/test_urls.py b/api/tests/test_urls.py index 9e4449a32..e48861bc1 100644 --- a/api/tests/test_urls.py +++ b/api/tests/test_urls.py @@ -12,5 +12,5 @@ def test_can_resolve_subsonic(): def test_can_resolve_v2(): - path = reverse("api:v2:instance:nodeinfo-2.0") - assert path == "/api/v2/instance/nodeinfo/2.0" + path = reverse("api:v2:instance:nodeinfo-2.1") + assert path == "/api/v2/instance/nodeinfo/2.1" diff --git a/changes/changelog.d/2085.feature b/changes/changelog.d/2085.feature new file mode 100644 index 000000000..aa9b3e308 --- /dev/null +++ b/changes/changelog.d/2085.feature @@ -0,0 +1 @@ +Add NodeInfo 2.1 (#2085)