feat(api): Add NodeInfo 2.1

Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2604>
environments/review-docs-2085-h577jp/deployments/18808
Georg Krause 2023-11-16 09:10:05 +00:00
rodzic 71140d5a9b
commit a0ae9bbb70
9 zmienionych plików z 263 dodań i 29 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1 @@
Add NodeInfo 2.1 (#2085)