feat(api): Add NodeInfo 2.1
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2604>
This commit is contained in:
parent
71140d5a9b
commit
a0ae9bbb70
|
@ -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/",
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
]
|
|
@ -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"))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Add NodeInfo 2.1 (#2085)
|
Loading…
Reference in New Issue