Feat(API): changes to support new frontend

Co-Authored-By: ArneBo <arne@ecobasa.org>
Co-Authored-By: Flupsi <upsiflu@gmail.com>
Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
jon r 2025-04-18 10:37:04 +02:00
parent 06f0a8f1e3
commit 90b853b722
18 changed files with 351 additions and 18 deletions

View File

@ -1398,6 +1398,7 @@ VERSATILEIMAGEFIELD_RENDITION_KEY_SETS = {
], ],
"attachment_square": [ "attachment_square": [
("original", "url"), ("original", "url"),
("small_square_crop", "crop__50x50"),
("medium_square_crop", "crop__200x200"), ("medium_square_crop", "crop__200x200"),
("large_square_crop", "crop__600x600"), ("large_square_crop", "crop__600x600"),
], ],

View File

@ -263,6 +263,7 @@ class ChannelSerializer(serializers.ModelSerializer):
attributed_to = federation_serializers.APIActorSerializer() attributed_to = federation_serializers.APIActorSerializer()
rss_url = serializers.CharField(source="get_rss_url") rss_url = serializers.CharField(source="get_rss_url")
url = serializers.SerializerMethodField() url = serializers.SerializerMethodField()
subscriptions_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Channel model = models.Channel
@ -276,6 +277,7 @@ class ChannelSerializer(serializers.ModelSerializer):
"rss_url", "rss_url",
"url", "url",
"downloads_count", "downloads_count",
"subscriptions_count",
] ]
def to_representation(self, obj): def to_representation(self, obj):
@ -284,6 +286,7 @@ class ChannelSerializer(serializers.ModelSerializer):
data["subscriptions_count"] = self.get_subscriptions_count(obj) data["subscriptions_count"] = self.get_subscriptions_count(obj)
return data return data
@extend_schema_field(OpenApiTypes.INT)
def get_subscriptions_count(self, obj) -> int: def get_subscriptions_count(self, obj) -> int:
return obj.actor.received_follows.exclude(approved=False).count() return obj.actor.received_follows.exclude(approved=False).count()

View File

@ -2,10 +2,12 @@ from django import http
from django.db import transaction from django.db import transaction
from django.db.models import Count, Prefetch, Q, Sum from django.db.models import Count, Prefetch, Q, Sum
from django.utils import timezone from django.utils import timezone
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer
from rest_framework import decorators, exceptions, mixins from rest_framework import decorators, exceptions, mixins
from rest_framework import permissions as rest_permissions from rest_framework import permissions as rest_permissions
from rest_framework import response, viewsets from rest_framework import response
from rest_framework import serializers as rest_serializers
from rest_framework import viewsets
from funkwhale_api.common import locales, permissions, preferences from funkwhale_api.common import locales, permissions, preferences
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
@ -210,6 +212,32 @@ class ChannelViewSet(
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads) data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
return response.Response(data, status=200) return response.Response(data, status=200)
@extend_schema(
responses=inline_serializer(
name="MetedataChoicesSerializer",
fields={
"language": rest_serializers.ListField(
child=inline_serializer(
name="LanguageItem",
fields={
"value": rest_serializers.CharField(),
"label": rest_serializers.CharField(),
},
)
),
"itunes_category": rest_serializers.ListField(
child=inline_serializer(
name="iTunesCategoryItem",
fields={
"value": rest_serializers.CharField(),
"label": rest_serializers.CharField(),
"children": rest_serializers.CharField(),
},
)
),
},
)
)
@decorators.action( @decorators.action(
methods=["get"], methods=["get"],
detail=False, detail=False,

View File

@ -257,6 +257,13 @@ class Attachment(models.Model):
proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid}) proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
return federation_utils.full_url(proxy_url + "?next=original") return federation_utils.full_url(proxy_url + "?next=original")
@property
def download_url_small_square_crop(self):
if self.file:
return utils.media_url(self.file.crop["50x50"].url)
proxy_url = reverse("api:v1:attachments-proxy", kwargs={"uuid": self.uuid})
return federation_utils.full_url(proxy_url + "?next=small_square_crop")
@property @property
def download_url_medium_square_crop(self): def download_url_medium_square_crop(self):
if self.file: if self.file:

View File

@ -2152,7 +2152,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Channel' $ref: '#/components/schemas/MetedataChoices'
description: '' description: ''
/api/v1/channels/rss-subscribe/: /api/v1/channels/rss-subscribe/:
post: post:
@ -9291,16 +9291,25 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
application/x-www-form-urlencoded: application/x-www-form-urlencoded:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
multipart/form-data: multipart/form-data:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
application/activity+json: application/activity+json:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
required: true
security: security:
- oauth2: [] - oauth2: []
- ApplicationToken: [] - ApplicationToken: []
@ -11653,7 +11662,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Channel' $ref: '#/components/schemas/MetedataChoices'
description: '' description: ''
/api/v2/channels/rss-subscribe/: /api/v2/channels/rss-subscribe/:
post: post:
@ -12927,7 +12936,7 @@ paths:
description: '' description: ''
/api/v2/instance/nodeinfo/2.1/: /api/v2/instance/nodeinfo/2.1/:
get: get:
operationId: getNodeInfo20_2 operationId: getNodeInfo21
tags: tags:
- instance - instance
responses: responses:
@ -12935,7 +12944,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/NodeInfo20' $ref: '#/components/schemas/NodeInfo21'
description: '' description: ''
/api/v2/instance/settings/: /api/v2/instance/settings/:
get: get:
@ -18957,16 +18966,25 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
application/x-www-form-urlencoded: application/x-www-form-urlencoded:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
multipart/form-data: multipart/form-data:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
application/activity+json: application/activity+json:
schema: schema:
$ref: '#/components/schemas/PatchedUploadForOwnerRequest' type: array
items:
$ref: '#/components/schemas/UploadBulkUpdateRequest'
required: true
security: security:
- oauth2: [] - oauth2: []
- ApplicationToken: [] - ApplicationToken: []
@ -20270,12 +20288,16 @@ components:
downloads_count: downloads_count:
type: integer type: integer
readOnly: true readOnly: true
subscriptions_count:
type: integer
readOnly: true
required: required:
- actor - actor
- artist - artist
- attributed_to - attributed_to
- downloads_count - downloads_count
- rss_url - rss_url
- subscriptions_count
- url - url
ChannelCreate: ChannelCreate:
type: object type: object
@ -20926,6 +20948,16 @@ components:
required: required:
- channel - channel
- uuid - uuid
LanguageItem:
type: object
properties:
value:
type: string
label:
type: string
required:
- label
- value
Library: Library:
type: object type: object
properties: properties:
@ -23124,6 +23156,124 @@ components:
- shortDescription - shortDescription
- supportedUploadExtensions - supportedUploadExtensions
- terms - terms
Metadata21:
type: object
properties:
actorId:
type: string
private:
type: boolean
readOnly: true
shortDescription:
type: string
readOnly: true
longDescription:
type: string
readOnly: true
contactEmail:
type: string
readOnly: true
nodeName:
type: string
readOnly: true
banner:
type: string
readOnly: true
defaultUploadQuota:
type: integer
readOnly: true
supportedUploadExtensions:
type: array
items:
type: string
allowList:
allOf:
- $ref: '#/components/schemas/AllowListStat'
readOnly: true
funkwhaleSupportMessageEnabled:
type: boolean
readOnly: true
instanceSupportMessage:
type: string
readOnly: true
usage:
$ref: '#/components/schemas/MetadataUsage'
languages:
type: array
items:
type: string
location:
type: string
content:
$ref: '#/components/schemas/MetadataContent'
features:
type: array
items:
type: string
codeOfConduct:
type: string
readOnly: true
required:
- actorId
- allowList
- banner
- codeOfConduct
- contactEmail
- content
- defaultUploadQuota
- features
- funkwhaleSupportMessageEnabled
- instanceSupportMessage
- languages
- location
- longDescription
- nodeName
- private
- shortDescription
- supportedUploadExtensions
MetadataContent:
type: object
properties:
local:
$ref: '#/components/schemas/MetadataContentLocal'
topMusicCategories:
type: array
items:
$ref: '#/components/schemas/MetadataContentCategory'
topPodcastCategories:
type: array
items:
$ref: '#/components/schemas/MetadataContentCategory'
required:
- local
- topMusicCategories
- topPodcastCategories
MetadataContentCategory:
type: object
properties:
name:
type: string
count:
type: integer
required:
- count
- name
MetadataContentLocal:
type: object
properties:
artists:
type: integer
releases:
type: integer
recordings:
type: integer
hoursOfContent:
type: integer
required:
- artists
- hoursOfContent
- recordings
- releases
MetadataUsage: MetadataUsage:
type: object type: object
properties: properties:
@ -23146,6 +23296,20 @@ components:
readOnly: true readOnly: true
required: required:
- tracks - tracks
MetedataChoices:
type: object
properties:
language:
type: array
items:
$ref: '#/components/schemas/LanguageItem'
itunes_category:
type: array
items:
$ref: '#/components/schemas/iTunesCategoryItem'
required:
- itunes_category
- language
ModerationTarget: ModerationTarget:
type: object type: object
properties: properties:
@ -23248,6 +23412,42 @@ components:
- software - software
- usage - usage
- version - version
NodeInfo21:
type: object
properties:
version:
type: string
readOnly: true
software:
$ref: '#/components/schemas/SoftwareSerializer_v2'
protocols:
type: array
items: {}
readOnly: true
services:
allOf:
- $ref: '#/components/schemas/Services'
default:
inbound: []
outbound: []
openRegistrations:
type: boolean
readOnly: true
usage:
allOf:
- $ref: '#/components/schemas/Usage'
readOnly: true
metadata:
allOf:
- $ref: '#/components/schemas/Metadata21'
readOnly: true
required:
- metadata
- openRegistrations
- protocols
- software
- usage
- version
NodeInfoLibrary: NodeInfoLibrary:
type: object type: object
properties: properties:
@ -24425,6 +24625,10 @@ components:
maxLength: 100 maxLength: 100
privacy_level: privacy_level:
$ref: '#/components/schemas/PrivacyLevelEnum' $ref: '#/components/schemas/PrivacyLevelEnum'
description:
type: string
nullable: true
maxLength: 5000
PatchedRadioRequest: PatchedRadioRequest:
type: object type: object
properties: properties:
@ -24455,6 +24659,8 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/ImportStatusEnum' - $ref: '#/components/schemas/ImportStatusEnum'
default: pending default: pending
privacy_level:
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
import_metadata: {} import_metadata: {}
import_reference: import_reference:
type: string type: string
@ -24546,6 +24752,10 @@ components:
is_playable: is_playable:
type: boolean type: boolean
readOnly: true readOnly: true
description:
type: string
nullable: true
maxLength: 5000
required: required:
- actor - actor
- album_covers - album_covers
@ -24576,6 +24786,10 @@ components:
maxLength: 100 maxLength: 100
privacy_level: privacy_level:
$ref: '#/components/schemas/PrivacyLevelEnum' $ref: '#/components/schemas/PrivacyLevelEnum'
description:
type: string
nullable: true
maxLength: 5000
required: required:
- name - name
PlaylistTrack: PlaylistTrack:
@ -25056,6 +25270,25 @@ components:
required: required:
- name - name
- version - version
SoftwareSerializer_v2:
type: object
properties:
name:
type: string
readOnly: true
version:
type: string
repository:
type: string
readOnly: true
homepage:
type: string
readOnly: true
required:
- homepage
- name
- repository
- version
SpaManifest: SpaManifest:
type: object type: object
properties: properties:
@ -25494,6 +25727,17 @@ components:
- mimetype - mimetype
- size - size
- uuid - uuid
UploadBulkUpdateRequest:
type: object
properties:
uuid:
type: string
format: uuid
privacy_level:
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
required:
- privacy_level
- uuid
UploadForOwner: UploadForOwner:
type: object type: object
properties: properties:
@ -25540,6 +25784,8 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/ImportStatusEnum' - $ref: '#/components/schemas/ImportStatusEnum'
default: pending default: pending
privacy_level:
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
import_details: import_details:
readOnly: true readOnly: true
import_metadata: {} import_metadata: {}
@ -25580,6 +25826,8 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/ImportStatusEnum' - $ref: '#/components/schemas/ImportStatusEnum'
default: pending default: pending
privacy_level:
$ref: '#/components/schemas/LibraryPrivacyLevelEnum'
import_metadata: {} import_metadata: {}
import_reference: import_reference:
type: string type: string
@ -25845,6 +26093,19 @@ components:
minLength: 1 minLength: 1
required: required:
- key - key
iTunesCategoryItem:
type: object
properties:
value:
type: string
label:
type: string
children:
type: string
required:
- children
- label
- value
securitySchemes: securitySchemes:
ApplicationToken: ApplicationToken:
type: http type: http

View File

@ -308,6 +308,7 @@ class AttachmentSerializer(serializers.Serializer):
urls = {} urls = {}
urls["source"] = o.url urls["source"] = o.url
urls["original"] = o.download_url_original urls["original"] = o.download_url_original
urls["small_square_crop"] = o.download_url_small_square_crop
urls["medium_square_crop"] = o.download_url_medium_square_crop urls["medium_square_crop"] = o.download_url_medium_square_crop
urls["large_square_crop"] = o.download_url_large_square_crop urls["large_square_crop"] = o.download_url_large_square_crop
return urls return urls

View File

@ -176,7 +176,12 @@ class AttachmentViewSet(
return r return r
size = request.GET.get("next", "original").lower() size = request.GET.get("next", "original").lower()
if size not in ["original", "medium_square_crop", "large_square_crop"]: if size not in [
"original",
"small_square_crop",
"medium_square_crop",
"large_square_crop",
]:
size = "original" size = "original"
try: try:

View File

@ -126,7 +126,7 @@ class NodeInfo21(NodeInfo20):
serializer_class = serializers.NodeInfo21Serializer serializer_class = serializers.NodeInfo21Serializer
@extend_schema( @extend_schema(
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20" responses=serializers.NodeInfo21Serializer, operation_id="getNodeInfo21"
) )
def get(self, request): def get(self, request):
pref = preferences.all() pref = preferences.all()

View File

@ -129,7 +129,7 @@ class Format(types.MultipleChoicePreference):
("aac", "aac"), ("aac", "aac"),
("mp3", "mp3"), ("mp3", "mp3"),
] ]
help_text = "Witch audio format to allow" help_text = "Which audio format to allow"
@global_preferences_registry.register @global_preferences_registry.register

View File

@ -372,6 +372,9 @@ class UploadSerializer(serializers.ModelSerializer):
required=False, required=False,
filters=lambda context: {"actor": context["user"].actor}, filters=lambda context: {"actor": context["user"].actor},
) )
privacy_level = serializers.ChoiceField(
choices=models.LIBRARY_PRIVACY_LEVEL_CHOICES, required=False
)
channel = common_serializers.RelatedField( channel = common_serializers.RelatedField(
"uuid", "uuid",
ChannelSerializer(), ChannelSerializer(),
@ -395,6 +398,7 @@ class UploadSerializer(serializers.ModelSerializer):
"size", "size",
"import_date", "import_date",
"import_status", "import_status",
"privacy_level",
] ]
read_only_fields = [ read_only_fields = [
@ -495,6 +499,7 @@ class UploadForOwnerSerializer(UploadSerializer):
r = super().to_representation(obj) r = super().to_representation(obj)
if "audio_file" in r: if "audio_file" in r:
del r["audio_file"] del r["audio_file"]
r["privacy_level"] = obj.library.privacy_level
return r return r
def validate(self, validated_data): def validate(self, validated_data):

View File

@ -798,6 +798,9 @@ class UploadViewSet(
cover_data["content"] = base64.b64encode(cover_data["content"]) cover_data["content"] = base64.b64encode(cover_data["content"])
return Response(payload, status=200) return Response(payload, status=200)
@extend_schema(
request=serializers.UploadBulkUpdateSerializer(many=True),
)
@action(detail=False, methods=["patch"]) @action(detail=False, methods=["patch"])
def bulk_update(self, request, *args, **kwargs): def bulk_update(self, request, *args, **kwargs):
""" """
@ -811,7 +814,9 @@ class UploadViewSet(
models.Upload.objects.bulk_update(serializer.validated_data, ["library"]) models.Upload.objects.bulk_update(serializer.validated_data, ["library"])
return Response( return Response(
serializers.UploadForOwnerSerializer(serializer.validated_data).data, serializers.UploadForOwnerSerializer(
serializer.validated_data, many=True
).data,
status=200, status=200,
) )

View File

@ -49,6 +49,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
"duration", "duration",
"is_playable", "is_playable",
"actor", "actor",
"description",
) )
read_only_fields = ["id", "modification_date", "creation_date"] read_only_fields = ["id", "modification_date", "creation_date"]

View File

@ -111,6 +111,9 @@ class GetArtistInfo2Serializer(serializers.Serializer):
if artist.mbid: if artist.mbid:
payload["musicBrainzId"] = TagValue(artist.mbid) payload["musicBrainzId"] = TagValue(artist.mbid)
if artist.attachment_cover: if artist.attachment_cover:
payload["smallImageUrl"] = TagValue(
artist.attachment_cover.download_url_small_square_crop
)
payload["mediumImageUrl"] = TagValue( payload["mediumImageUrl"] = TagValue(
artist.attachment_cover.download_url_medium_square_crop artist.attachment_cover.download_url_medium_square_crop
) )

View File

@ -230,6 +230,7 @@ def test_channel_serializer_representation(factories, to_api_date):
"rss_url": channel.get_rss_url(), "rss_url": channel.get_rss_url(),
"url": channel.actor.url, "url": channel.actor.url,
"downloads_count": 12, "downloads_count": 12,
"subscriptions_count": 0,
} }
expected["artist"]["description"] = common_serializers.ContentSerializer( expected["artist"]["description"] = common_serializers.ContentSerializer(
content content
@ -254,6 +255,7 @@ def test_channel_serializer_external_representation(factories, to_api_date):
"rss_url": channel.get_rss_url(), "rss_url": channel.get_rss_url(),
"url": channel.actor.url, "url": channel.actor.url,
"downloads_count": 0, "downloads_count": 0,
"subscriptions_count": 0,
} }
expected["artist"]["description"] = common_serializers.ContentSerializer( expected["artist"]["description"] = common_serializers.ContentSerializer(
content content

View File

@ -195,6 +195,9 @@ def test_attachment_serializer_existing_file(factories, to_api_date):
"urls": { "urls": {
"source": attachment.url, "source": attachment.url,
"original": federation_utils.full_url(attachment.file.url), "original": federation_utils.full_url(attachment.file.url),
"small_square_crop": federation_utils.full_url(
attachment.file.crop["50x50"].url
),
"medium_square_crop": federation_utils.full_url( "medium_square_crop": federation_utils.full_url(
attachment.file.crop["200x200"].url attachment.file.crop["200x200"].url
), ),
@ -225,6 +228,9 @@ def test_attachment_serializer_remote_file(factories, to_api_date):
"urls": { "urls": {
"source": attachment.url, "source": attachment.url,
"original": federation_utils.full_url(proxy_url + "?next=original"), "original": federation_utils.full_url(proxy_url + "?next=original"),
"small_square_crop": federation_utils.full_url(
proxy_url + "?next=small_square_crop"
),
"medium_square_crop": federation_utils.full_url( "medium_square_crop": federation_utils.full_url(
proxy_url + "?next=medium_square_crop" proxy_url + "?next=medium_square_crop"
), ),

View File

@ -169,6 +169,7 @@ def test_upload_owner_serializer(factories, to_api_date):
"import_details": {"hello": "world"}, "import_details": {"hello": "world"},
"source": "upload://test", "source": "upload://test",
"import_reference": "ref", "import_reference": "ref",
"privacy_level": upload.library.privacy_level,
} }
serializer = serializers.UploadForOwnerSerializer(upload) serializer = serializers.UploadForOwnerSerializer(upload)
assert serializer.data == expected assert serializer.data == expected

View File

@ -85,6 +85,7 @@ def test_playlist_serializer(factories, to_api_date):
"duration": 0, "duration": 0,
"tracks_count": 0, "tracks_count": 0,
"album_covers": [], "album_covers": [],
"description": playlist.description,
} }
serializer = serializers.PlaylistSerializer(playlist) serializer = serializers.PlaylistSerializer(playlist)

View File

@ -156,6 +156,9 @@ def test_get_artist_info_2_serializer(factories):
expected = { expected = {
"musicBrainzId": artist.mbid, "musicBrainzId": artist.mbid,
"smallImageUrl": renderers.TagValue(
artist.attachment_cover.download_url_small_square_crop
),
"mediumImageUrl": renderers.TagValue( "mediumImageUrl": renderers.TagValue(
artist.attachment_cover.download_url_medium_square_crop artist.attachment_cover.download_url_medium_square_crop
), ),