Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2422-trigger-libraries-follow-on-user-follow
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • update-frontend-dependencies
  • upload-process-spec
  • user-concept-docs
  • v2-artists
  • vite-ws-ssl-compatible
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 1121-download
  • 1218-smartplaylist_backend
  • 1288-user-me-can-be-created-but-cannot-be-edited
  • 1376-new-about-page
  • 1381-progress-bars
  • 1434-update-pyld
  • 1481
  • 1515-update-click
  • 1518-update-django-allauth
  • 1603-disable-dependency-job
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • adds-album-queryset-with_duration
  • album-sliders
  • back-option-for-edits
  • cloo-master-patch-96951
  • develop
  • master
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • profile-menu-redesign
  • stable
  • update-boto3
  • update-frontend-dependencies
  • update-uvicorn
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
96 results
Show changes
Showing
with 1318 additions and 417 deletions
import logging
import os
import re
import urllib.parse
import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from rest_framework import serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, utils
logger = logging.getLogger(__name__)
......@@ -116,7 +120,7 @@ class MediaSerializer(jsonld.JsonLdSerializer):
if not is_mimetype(v, self.allowed_mimetypes):
raise serializers.ValidationError(
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
f"Invalid mimetype {v}. Allowed: {self.allowed_mimetypes}"
)
return v
......@@ -237,7 +241,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
)
preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
manuallyApprovesFollowers = serializers.BooleanField(
required=False, allow_null=True
)
name = serializers.CharField(
required=False, max_length=200, allow_blank=True, allow_null=True
)
......@@ -245,6 +251,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False,
allow_null=True,
allow_blank=True,
)
followers = serializers.URLField(max_length=500, required=False)
following = serializers.URLField(max_length=500, required=False, allow_null=True)
......@@ -337,9 +344,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["url"] = [
{
"type": "Link",
"href": instance.channel.get_absolute_url()
"href": (
instance.channel.get_absolute_url()
if instance.channel.artist.is_local
else instance.get_absolute_url(),
else instance.get_absolute_url()
),
"mediaType": "text/html",
},
{
......@@ -369,7 +378,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["publicKey"] = {
"owner": instance.fid,
"publicKeyPem": instance.public_key,
"id": "{}#main-key".format(instance.fid),
"id": f"{instance.fid}#main-key",
}
ret["endpoints"] = {}
......@@ -433,9 +442,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file(
actor,
"attachment_icon",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
else None
),
)
rss_url = get_by_media_type(
......@@ -451,7 +462,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
actor,
rss_url=rss_url,
attributed_to_fid=attributed_to,
**self.validated_data
**self.validated_data,
)
return actor
......@@ -488,9 +499,11 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
common_utils.attach_file(
artist,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
else None
),
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(artist, *tags)
......@@ -500,7 +513,10 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
reverse("federation:music:libraries-detail", kwargs={"uuid": uid})
)
library = attributed_to.libraries.create(
privacy_level="everyone", name=artist_defaults["name"], fid=fid, uuid=uid,
privacy_level="everyone",
name=artist_defaults["name"],
fid=fid,
uuid=uid,
)
else:
library = artist.channel.library
......@@ -512,7 +528,9 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
"library": library,
}
channel, created = audio_models.Channel.objects.update_or_create(
actor=actor, attributed_to=attributed_to, defaults=channel_defaults,
actor=actor,
attributed_to=attributed_to,
defaults=channel_defaults,
)
return channel
......@@ -636,7 +654,6 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs):
target = self.validated_data["object"]
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
else:
......@@ -729,9 +746,7 @@ class FollowActionSerializer(serializers.Serializer):
.get()
)
except follow_class.DoesNotExist:
raise serializers.ValidationError(
"No follow to {}".format(self.action_type)
)
raise serializers.ValidationError(f"No follow to {self.action_type}")
return validated_data
def to_representation(self, instance):
......@@ -742,7 +757,7 @@ class FollowActionSerializer(serializers.Serializer):
return {
"@context": jsonld.get_default_context(),
"id": instance.get_federation_id() + "/{}".format(self.action_type),
"id": instance.get_federation_id() + f"/{self.action_type}",
"type": self.action_type.title(),
"actor": actor.fid,
"object": FollowSerializer(instance).data,
......@@ -750,7 +765,6 @@ class FollowActionSerializer(serializers.Serializer):
class AcceptFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Accept"])
action_type = "accept"
......@@ -764,7 +778,6 @@ class AcceptFollowSerializer(FollowActionSerializer):
class RejectFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Reject"])
action_type = "reject"
......@@ -808,7 +821,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target
).get()
except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove")
raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data
def to_representation(self, instance):
......@@ -848,7 +863,7 @@ class ActorWebfingerSerializer(serializers.Serializer):
def to_representation(self, instance):
data = {}
data["subject"] = "acct:{}".format(instance.webfinger_subject)
data["subject"] = f"acct:{instance.webfinger_subject}"
data["links"] = [
{"rel": "self", "href": instance.fid, "type": "application/activity+json"}
]
......@@ -874,8 +889,7 @@ class ActivitySerializer(serializers.Serializer):
try:
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError("Unsupported type {}".format(type))
raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
......@@ -926,10 +940,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
def get_additional_fields(data):
UNSET = object()
additional_fields = {}
for field in ["name", "summary"]:
for field in ["name", "summary", "library", "audience", "published"]:
v = data.get(field, UNSET)
if v == UNSET:
continue
# in some cases we use the serializer context to pass objects instances, we don't want to add them
if not isinstance(v, str) or isinstance(v, dict):
continue
additional_fields[field] = v
return additional_fields
......@@ -960,7 +977,7 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
first = common_utils.set_query_parameter(conf["id"], page=1)
current = first
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = {
data = {
"id": conf["id"],
"attributedTo": conf["actor"].fid,
"totalItems": paginator.count,
......@@ -969,10 +986,10 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
"first": first,
"last": last,
}
d.update(get_additional_fields(conf))
data.update(get_additional_fields(conf))
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
class LibrarySerializer(PaginatedCollectionSerializer):
......@@ -982,8 +999,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField(
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False,
......@@ -1000,9 +1015,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
PAGINATED_COLLECTION_JSONLD_MAPPING,
{
"name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers),
"actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
},
......@@ -1024,18 +1037,20 @@ class LibrarySerializer(PaginatedCollectionSerializer):
conf = {
"id": library.fid,
"name": library.name,
"summary": library.description,
"page_size": 100,
"attributedTo": library.actor,
"actor": library.actor,
"items": library.uploads.for_federation(),
"items": (
library.uploads.for_federation()
if not library.playlist_uploads.all()
else library.playlist_uploads.for_federation()
),
"type": "Library",
}
r = super().to_representation(conf)
r["audience"] = (
contexts.AS.Public if library.privacy_level == "everyone" else ""
)
r["followers"] = library.followers_url
return r
def create(self, validated_data):
......@@ -1055,8 +1070,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
defaults={
"uploads_count": validated_data["totalItems"],
"name": validated_data["name"],
"description": validated_data.get("summary"),
"followers_url": validated_data["followers"],
"privacy_level": privacy[validated_data["audience"]],
},
)
......@@ -1123,7 +1136,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
"last": last,
"items": [
conf["item_serializer"](
i, context={"actor": conf["actor"], "include_ap_context": False}
i,
context={
"actor": conf["actor"],
"library": conf.get("library", None),
"include_ap_context": False,
},
).data
for i in page.object_list
],
......@@ -1158,7 +1176,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
def repr_tag(tag_name):
return {"type": "Hashtag", "name": "#{}".format(tag_name)}
return {"type": "Hashtag", "name": f"#{tag_name}"}
def include_content(repr, content_obj):
......@@ -1217,12 +1235,22 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
self.updateable_fields, validated_data, instance
)
updated_fields = self.validate_updated_data(instance, updated_fields)
set_ac = False
if "artist_credit" in updated_fields:
artist_credit = updated_fields.pop("artist_credit")
set_ac = True
if creating:
instance, created = self.Meta.model.objects.get_or_create(
fid=validated_data["id"], defaults=updated_fields
)
if set_ac:
instance.artist_credit.set(artist_credit)
else:
music_tasks.update_library_entity(instance, updated_fields)
obj = music_tasks.update_library_entity(instance, updated_fields)
if set_ac:
obj.artist_credit.set(artist_credit)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags)
......@@ -1284,7 +1312,6 @@ class ArtistSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"image": jsonld.first_obj(contexts.AS.image),
},
)
......@@ -1296,9 +1323,9 @@ class ArtistSerializer(MusicEntitySerializer):
"name": instance.name,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
......@@ -1310,12 +1337,53 @@ class ArtistSerializer(MusicEntitySerializer):
create = MusicEntitySerializer.update_or_create
class ArtistCreditSerializer(jsonld.JsonLdSerializer):
artist = ArtistSerializer()
joinphrase = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
credit = serializers.CharField(
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
)
published = serializers.DateTimeField()
id = serializers.URLField(max_length=500)
updateable_fields = [
("credit", "credit"),
("artist", "artist"),
("joinphrase", "joinphrase"),
]
class Meta:
model = music_models.ArtistCredit
jsonld_mapping = {
"artist": jsonld.first_obj(contexts.FW.artist),
"credit": jsonld.first_val(contexts.FW.credit),
"index": jsonld.first_val(contexts.FW.index),
"joinphrase": jsonld.first_val(contexts.FW.joinphrase),
"published": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, instance):
data = {
"type": "ArtistCredit",
"id": instance.fid,
"artist": ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data,
"joinphrase": instance.joinphrase,
"credit": instance.credit,
"index": instance.index,
"published": instance.creation_date.isoformat(),
}
if self.context.get("include_ap_context", self.parent is None):
data["@context"] = jsonld.get_default_context()
return data
class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField(
child=MultipleSerializer(allowed=[BasicActorSerializer, ArtistSerializer]),
min_length=1,
)
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
image = ImageSerializer(
allowed_mimetypes=["image/*"],
allow_null=True,
......@@ -1328,7 +1396,7 @@ class AlbumSerializer(MusicEntitySerializer):
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("released", "release_date"),
("_artist", "artist"),
("artist_credit", "artist_credit"),
]
class Meta:
......@@ -1337,62 +1405,60 @@ class AlbumSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING,
{
"released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"image": jsonld.first_obj(contexts.AS.image),
},
)
def to_representation(self, instance):
d = {
data = {
"type": "Album",
"id": instance.fid,
"name": instance.title,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": instance.release_date.isoformat()
if instance.release_date
else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"released": (
instance.release_date.isoformat() if instance.release_date else None
),
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
if instance.artist.get_channel():
d["artists"] = [
{
"type": instance.artist.channel.actor.type,
"id": instance.artist.channel.actor.fid,
}
]
else:
d["artists"] = [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
data["artist_credit"] = ArtistCreditSerializer(
instance.artist_credit.all(),
context={"include_ap_context": False},
many=True,
).data
]
include_content(d, instance.description)
include_content(data, instance.description)
if instance.attachment_cover:
include_image(d, instance.attachment_cover)
include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
def validate(self, data):
validated_data = super().validate(data)
if not self.parent:
artist_data = validated_data["artists"][0]
if artist_data.get("type", "Artist") == "Artist":
validated_data["_artist"] = utils.retrieve_ap_object(
artist_data["id"],
artist_credit_data = validated_data["artist_credit"]
if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
acs = []
for ac in validated_data["artist_credit"]:
acs.append(
utils.retrieve_ap_object(
ac["id"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Artist,
serializer_class=ArtistSerializer,
queryset=music_models.ArtistCredit,
serializer_class=ArtistCreditSerializer,
)
)
validated_data["artist_credit"] = acs
else:
# we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_data["id"])
validated_data["_artist"] = actor.channel.artist
actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
return validated_data
......@@ -1402,7 +1468,7 @@ class AlbumSerializer(MusicEntitySerializer):
class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False)
......@@ -1430,7 +1496,7 @@ class TrackSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING,
{
"album": jsonld.first_obj(contexts.FW.album),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
"copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license),
......@@ -1440,7 +1506,7 @@ class TrackSerializer(MusicEntitySerializer):
)
def to_representation(self, instance):
d = {
data = {
"type": "Track",
"id": instance.fid,
"name": instance.title,
......@@ -1448,29 +1514,32 @@ class TrackSerializer(MusicEntitySerializer):
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position,
"disc": instance.disc_number,
"license": instance.local_license["identifiers"][0]
"license": (
instance.local_license["identifiers"][0]
if instance.local_license
else None,
else None
),
"copyright": instance.copyright if instance.copyright else None,
"artists": [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data
],
"artist_credit": ArtistCreditSerializer(
instance.artist_credit.all(),
context={"include_ap_context": False},
many=True,
).data,
"album": AlbumSerializer(
instance.album, context={"include_ap_context": False}
).data,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
include_image(d, instance.attachment_cover)
include_content(data, instance.description)
include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context()
return d
data["@context"] = jsonld.get_default_context()
return data
@transaction.atomic
def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks
......@@ -1486,18 +1555,21 @@ class TrackSerializer(MusicEntitySerializer):
validated_data, "album.attributedTo", permissive=True
)
)
artists = (
common_utils.recursive_getattr(validated_data, "artists", permissive=True)
artist_credit = (
common_utils.recursive_getattr(
validated_data, "artist_credit", permissive=True
)
or []
)
album_artists = (
album_artists_credit = (
common_utils.recursive_getattr(
validated_data, "album.artists", permissive=True
validated_data, "album.artist_credit", permissive=True
)
or []
)
for artist in artists + album_artists:
actors_to_fetch.add(artist.get("attributedTo"))
for ac in artist_credit + album_artists_credit:
actors_to_fetch.add(ac["artist"].get("attributedTo"))
for url in actors_to_fetch:
if not url:
......@@ -1510,8 +1582,9 @@ class TrackSerializer(MusicEntitySerializer):
from_activity = self.context.get("activity")
if from_activity:
metadata["from_activity_id"] = from_activity.pk
track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True)
track = music_tasks.get_track_from_import_metadata(
metadata, update_cover=True, query_mb=False
)
return track
def update(self, obj, validated_data):
......@@ -1520,6 +1593,50 @@ class TrackSerializer(MusicEntitySerializer):
return super().update(obj, validated_data)
def duration_int_to_xml(duration):
if not duration:
return None
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
ret = "P"
days, seconds = divmod(int(duration), multipliers["D"])
ret += f"{days:d}DT" if days > 0 else "T"
hours, seconds = divmod(seconds, multipliers["H"])
ret += f"{hours:d}H" if hours > 0 else ""
minutes, seconds = divmod(seconds, multipliers["M"])
ret += f"{minutes:d}M" if minutes > 0 else ""
ret += f"{seconds:d}S" if seconds > 0 or ret == "PT" else ""
return ret
class DayTimeDurationSerializer(serializers.DurationField):
multipliers = {"S": 1, "M": 60, "H": 3600, "D": 86400}
def to_internal_value(self, value):
if isinstance(value, float):
return value
parsed = re.match(
r"P([0-9]+D)?T([0-9]+H)?([0-9]+M)?([0-9]+(?:\.[0-9]+)?S)?", str(value)
)
if parsed is not None:
return int(
sum(
[
self.multipliers[s[-1]] * float("0" + s[:-1])
for s in parsed.groups()
if s is not None
]
)
)
self.fail(
"invalid", format="https://www.w3.org/TR/xmlschema11-2/#dayTimeDuration"
)
def to_representation(self, value):
duration_int_to_xml(value)
class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
id = serializers.URLField(max_length=500)
......@@ -1529,7 +1646,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
updated = serializers.DateTimeField(required=False, allow_null=True)
bitrate = serializers.IntegerField(min_value=0)
size = serializers.IntegerField(min_value=0)
duration = serializers.IntegerField(min_value=0)
duration = DayTimeDurationSerializer(min_value=0)
track = TrackSerializer(required=True)
......@@ -1565,8 +1682,9 @@ class UploadSerializer(jsonld.JsonLdSerializer):
def validate_library(self, v):
lb = self.context.get("library")
if lb:
if lb.fid != v:
raise serializers.ValidationError("Invalid library")
# the upload can come from a playlist lib
if lb.fid != v and not lb.playlist.library and lb.playlist.library.fid != v:
raise serializers.ValidationError("Invalid library fid")
return lb
actor = self.context.get("actor")
......@@ -1578,10 +1696,10 @@ class UploadSerializer(jsonld.JsonLdSerializer):
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
except Exception:
raise serializers.ValidationError("Invalid library")
except Exception as e:
raise serializers.ValidationError(f"Invalid library : {e}")
if actor and library.actor != actor:
raise serializers.ValidationError("Invalid library")
raise serializers.ValidationError("Invalid library, actor check fails")
return library
def update(self, instance, validated_data):
......@@ -1632,16 +1750,17 @@ class UploadSerializer(jsonld.JsonLdSerializer):
return music_models.Upload.objects.create(**data)
def to_representation(self, instance):
lib = instance.library if instance.library else self.context.get("library")
track = instance.track
d = {
"type": "Audio",
"id": instance.get_federation_id(),
"library": instance.library.fid,
"library": lib.fid,
"name": track.full_name,
"published": instance.creation_date.isoformat(),
"bitrate": instance.bitrate,
"size": instance.size,
"duration": instance.duration,
"duration": duration_int_to_xml(instance.duration),
"url": [
{
"href": utils.full_url(instance.listen_url_no_download),
......@@ -1655,10 +1774,8 @@ class UploadSerializer(jsonld.JsonLdSerializer):
},
],
"track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": contexts.AS.Public
if instance.library.privacy_level == "everyone"
else "",
"attributedTo": instance.library.actor.fid,
"to": (contexts.AS.Public if lib.privacy_level == "everyone" else ""),
"attributedTo": lib.actor.fid,
}
if instance.modification_date:
d["updated"] = instance.modification_date.isoformat()
......@@ -1697,9 +1814,7 @@ class FlagSerializer(jsonld.JsonLdSerializer):
try:
return utils.get_object_by_fid(v, local=True)
except ObjectDoesNotExist:
raise serializers.ValidationError(
"Unknown id {} for reported object".format(v)
)
raise serializers.ValidationError(f"Unknown id {v} for reported object")
def validate_type(self, tags):
if tags:
......@@ -1734,7 +1849,8 @@ class FlagSerializer(jsonld.JsonLdSerializer):
}
report, created = moderation_models.Report.objects.update_or_create(
fid=validated_data["id"], defaults=kwargs,
fid=validated_data["id"],
defaults=kwargs,
)
moderation_signals.report_created.send(sender=None, report=report)
return report
......@@ -1777,7 +1893,7 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer):
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.filter(track__artist=channel.artist),
.filter(track__artist_credit__artist=channel.artist),
"type": "OrderedCollection",
}
r = super().to_representation(conf)
......@@ -1788,16 +1904,15 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1)
name = TruncatedCharField(truncate_length=music_models.MAX_LENGTHS["TRACK_TITLE"])
name = serializers.CharField()
published = serializers.DateTimeField(required=False)
duration = serializers.IntegerField(min_value=0, required=False)
duration = DayTimeDurationSerializer(required=False)
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
album = serializers.URLField(max_length=500, required=False)
license = serializers.URLField(allow_null=True, required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
copyright = TruncatedCharField(
truncate_length=music_models.MAX_LENGTHS["COPYRIGHT"],
copyright = serializers.CharField(
allow_null=True,
required=False,
)
......@@ -1848,7 +1963,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
actor=actors.get_service_actor(),
serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter(
artist__channel=self.context["channel"]
artist_credit__artist__channel=self.context["channel"]
),
)
......@@ -1879,9 +1994,9 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
"name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(),
"to": contexts.AS.Public
if upload.library.privacy_level == "everyone"
else "",
"to": (
contexts.AS.Public if upload.library.privacy_level == "everyone" else ""
),
"url": [
{
"type": "Link",
......@@ -1900,7 +2015,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
if upload.track.local_license:
data["license"] = upload.track.local_license["identifiers"][0]
include_if_not_none(data, upload.duration, "duration")
include_if_not_none(data, duration_int_to_xml(upload.duration), "duration")
include_if_not_none(data, upload.track.position, "position")
include_if_not_none(data, upload.track.disc_number, "disc")
include_if_not_none(data, upload.track.copyright, "copyright")
......@@ -1911,7 +2026,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
tags = [item.tag.name for item in upload.get_all_tagged_items()]
if tags:
data["tag"] = [repr_tag(name) for name in sorted(set(tags))]
data["summary"] = " ".join(["#{}".format(name) for name in tags])
data["summary"] = " ".join([f"#{name}" for name in tags])
if self.context.get("include_ap_context", True):
data["@context"] = jsonld.get_default_context()
......@@ -1927,7 +2042,6 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
now = timezone.now()
track_defaults = {
"fid": validated_data["id"],
"artist": channel.artist,
"position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1),
"title": validated_data["name"],
......@@ -1940,17 +2054,42 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create(
artist__channel=channel, fid=validated_data["id"], defaults=track_defaults
fid=validated_data["id"],
defaults=track_defaults,
)
# only one artist_credit per channel
query = (
Q(
artist=channel.artist,
)
& Q(credit__iexact=channel.artist.name)
& Q(joinphrase="")
)
defaults = {
"artist": channel.artist,
"joinphrase": "",
"credit": channel.artist.name,
}
ac_obj = music_tasks.get_best_candidate_or_create(
music_models.ArtistCredit,
query,
defaults=defaults,
sort_fields=["mbid", "fid"],
)
track.artist_credit.set([ac_obj[0].id])
if "image" in validated_data:
new_value = self.validated_data["image"]
common_utils.attach_file(
track,
"attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
else None
),
)
common_utils.attach_content(
......@@ -2032,7 +2171,7 @@ class DeleteSerializer(jsonld.JsonLdSerializer):
try:
obj = utils.get_object_by_fid(url)
except utils.ObjectDoesNotExist:
raise serializers.ValidationError("No object matching {}".format(url))
raise serializers.ValidationError(f"No object matching {url}")
if isinstance(obj, music_models.Upload):
obj = obj.track
......@@ -2074,3 +2213,284 @@ class IndexSerializer(jsonld.JsonLdSerializer):
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
"audience": favorite.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return favorites_models.TrackFavorite.objects.create(
fid=validated_data.get("id"),
uuid=uuid.uuid4(),
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class ListeningSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Listen])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
audience = serializers.CharField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
"audience": jsonld.first_id(contexts.AS.audience),
}
def to_representation(self, listening):
payload = {
"type": "Listen",
"id": listening.fid,
"actor": listening.actor.fid,
"object": listening.track.fid,
"audience": listening.privacy_level,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return history_models.Listening.objects.create(
fid=validated_data.get("id"),
uuid=validated_data["id"].rstrip("/").split("/")[-1],
actor=actor,
track=track,
privacy_level=validated_data["audience"],
)
class PlaylistTrackSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.FW.PlaylistTrack])
id = serializers.URLField(max_length=500)
track = serializers.URLField(max_length=500)
index = serializers.IntegerField()
creation_date = serializers.DateTimeField()
playlist = serializers.URLField(max_length=500, required=False)
class Meta:
model = playlists_models.PlaylistTrack
jsonld_mapping = {
"track": jsonld.first_id(contexts.FW.track),
"playlist": jsonld.first_id(contexts.FW.playlist),
"index": jsonld.first_val(contexts.FW.index),
"creation_date": jsonld.first_val(contexts.AS.published),
}
def to_representation(self, plt):
payload = {
"type": "PlaylistTrack",
"id": plt.fid,
"track": plt.track.fid,
"index": plt.index,
"attributedTo": plt.playlist.actor.fid,
"published": plt.creation_date.isoformat(),
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
if self.context.get("include_playlist", True):
payload["playlist"] = plt.playlist.fid
return payload
def create(self, validated_data):
track = utils.retrieve_ap_object(
validated_data["track"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Track,
serializer_class=TrackSerializer,
)
playlist = utils.retrieve_ap_object(
validated_data["playlist"],
actor=self.context.get("fetch_actor"),
queryset=playlists_models.Playlist,
serializer_class=PlaylistSerializer,
)
defaults = {
"track": track,
"index": validated_data["index"],
"creation_date": validated_data["creation_date"],
"playlist": playlist,
}
if existing_plt := playlists_models.PlaylistTrack.objects.filter(
playlist=playlist, index=validated_data["index"]
):
existing_plt.delete()
plt, created = playlists_models.PlaylistTrack.objects.update_or_create(
defaults,
**{
"uuid": validated_data["id"].rstrip("/").split("/")[-1],
"fid": validated_data["id"],
},
)
return plt
class PlaylistSerializer(jsonld.JsonLdSerializer):
"""
Used for playlist activities
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist, contexts.AS.Create])
id = serializers.URLField(max_length=500)
uuid = serializers.UUIDField(required=False)
name = serializers.CharField(required=False)
attributedTo = serializers.URLField(max_length=500, required=False)
published = serializers.DateTimeField(required=False)
updated = serializers.DateTimeField(required=False)
audience = serializers.ChoiceField(
choices=[None, "https://www.w3.org/ns/activitystreams#Public"],
required=False,
allow_null=True,
allow_blank=True,
)
library = serializers.URLField(max_length=500, required=True)
updateable_fields = [
("name", "title"),
("attributedTo", "attributed_to"),
]
class Meta:
model = playlists_models.Playlist
jsonld_mapping = common_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING,
{
"updated": jsonld.first_val(contexts.AS.published),
"audience": jsonld.first_id(contexts.AS.audience),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
"library": jsonld.first_id(contexts.FW.library),
},
)
def to_representation(self, playlist):
payload = {
"type": "Playlist",
"id": playlist.fid,
"name": playlist.name,
"attributedTo": playlist.actor.fid,
"published": playlist.creation_date.isoformat(),
"audience": playlist.privacy_level,
"library": playlist.library.fid,
}
payload["audience"] = (
contexts.AS.Public if playlist.privacy_level == "everyone" else ""
)
if playlist.modification_date:
payload["updated"] = playlist.modification_date.isoformat()
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = utils.retrieve_ap_object(
validated_data["attributedTo"],
actor=self.context.get("fetch_actor"),
queryset=models.Actor,
serializer_class=ActorSerializer,
)
library = utils.retrieve_ap_object(
validated_data["library"],
actor=self.context.get("fetch_actor"),
queryset=music_models.Library,
serializer_class=LibrarySerializer,
)
ap_to_fw_data = {
"actor": actor,
"name": validated_data["name"],
"creation_date": validated_data["published"],
"privacy_level": validated_data["audience"],
"library": library,
}
playlist, created = playlists_models.Playlist.objects.update_or_create(
defaults=ap_to_fw_data,
**{
"fid": validated_data["id"],
"uuid": validated_data.get(
"uuid", validated_data["id"].rstrip("/").split("/")[-1]
),
},
)
return playlist
def validate(self, data):
validated_data = super().validate(data)
if validated_data["audience"] in [
"https://www.w3.org/ns/activitystreams#Public",
"everyone",
]:
validated_data["audience"] = "everyone"
else:
validated_data.pop("audience")
return validated_data
def update(self, instance, validated_data):
return self.create(validated_data)
class PlaylistCollectionSerializer(PaginatedCollectionSerializer):
"""
Used for the federation view.
"""
type = serializers.ChoiceField(choices=[contexts.FW.Playlist])
def to_representation(self, playlist):
conf = {
"id": playlist.fid,
"name": playlist.name,
"page_size": 100,
"actor": playlist.actor,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"tracks",
),
"type": "Playlist",
"library": playlist.library.fid,
"published": playlist.creation_date.isoformat(),
}
r = super().to_representation(conf)
return r
import cryptography.exceptions
import datetime
import logging
import pytz
import sys
import cryptography.exceptions
import requests
import requests_http_message_signatures
from django import forms
from django.utils import timezone
from django.utils.http import parse_http_date
import requests
import requests_http_signature
from . import exceptions, utils
if sys.version_info < (3, 9):
from backports.zoneinfo import ZoneInfo
else:
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
# the request Date should be between now - 30s and now + 30s
......@@ -26,13 +30,16 @@ def verify_date(raw_date):
ts = parse_http_date(raw_date)
except ValueError as e:
raise forms.ValidationError(str(e))
dt = datetime.datetime.utcfromtimestamp(ts)
dt = dt.replace(tzinfo=pytz.utc)
dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR)
now = timezone.now()
if dt < now - delta or dt > now + delta:
logger.debug(
f"Request Date {raw_date} is too too far in the future or in the past"
)
raise forms.ValidationError(
"Request Date {} is too far in the future or in the past".format(raw_date)
"Request Date is too far in the future or in the past"
)
return dt
......@@ -45,8 +52,8 @@ def verify(request, public_key):
)
verify_date(date)
try:
return requests_http_signature.HTTPSignatureAuth.verify(
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False
return requests_http_message_signatures.HTTPSignatureHeaderAuth.verify(
request, key_resolver=lambda **kwargs: public_key
)
except cryptography.exceptions.InvalidSignature:
logger.warning(
......@@ -65,16 +72,16 @@ def verify_django(django_request, public_key):
headers = utils.clean_wsgi_headers(django_request.META)
for h, v in list(headers.items()):
# we include lower-cased version of the headers for compatibility
# with requests_http_signature
# with requests_http_message_signatures
headers[h.lower()] = v
try:
signature = headers["Signature"]
except KeyError:
raise exceptions.MissingSignature
url = "http://noop{}".format(django_request.path)
url = f"http://noop{django_request.path}"
query = django_request.META["QUERY_STRING"]
if query:
url += "?{}".format(query)
url += f"?{query}"
signature_headers = signature.split('headers="')[1].split('",')[0]
expected = signature_headers.split(" ")
logger.debug("Signature expected headers: %s", expected)
......@@ -98,8 +105,7 @@ def verify_django(django_request, public_key):
def get_auth(private_key, private_key_id):
return requests_http_signature.HTTPSignatureAuth(
use_auth_header=False,
return requests_http_message_signatures.HTTPSignatureHeaderAuth(
headers=["(request-target)", "user-agent", "host", "date"],
algorithm="rsa-sha256",
key=private_key.encode("utf-8"),
......
from django.conf import settings
from rest_framework import serializers
from funkwhale_api.common import preferences
from funkwhale_api.common import middleware
from funkwhale_api.common import utils
from funkwhale_api.common import middleware, preferences, utils
from funkwhale_api.federation import utils as federation_utils
from . import models
......
......@@ -2,35 +2,40 @@ import datetime
import json
import logging
import os
import requests
from urllib.parse import urlparse
import requests
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import Q, F
from django.db.models import F, Q
from django.db.models.deletion import Collector
from django.utils import timezone
from dynamic_preferences.registries import global_preferences_registry
from requests.exceptions import RequestException
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import preferences
from funkwhale_api.common import models as common_models
from funkwhale_api.common import session
from funkwhale_api.common import preferences, session
from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import mrf
from funkwhale_api.music import models as music_models
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.taskapp import celery
from . import activity
from . import actors
from . import exceptions
from . import jsonld
from . import keys
from . import models, signing
from . import serializers
from . import routes
from . import utils
from . import webfinger
from . import (
activity,
actors,
exceptions,
jsonld,
keys,
models,
routes,
serializers,
signing,
utils,
webfinger,
)
logger = logging.getLogger(__name__)
......@@ -137,11 +142,23 @@ def dispatch_outbox(activity):
"delivery",
)
def deliver_to_remote(delivery):
if not preferences.get("federation__enabled"):
# federation is disabled, we only deliver to local recipients
return
# we check the domain is still reachable before attempting delivery
if (
models.Domain.objects.get(name=urlparse(delivery.inbox_url).netloc).reachable
is False
):
delivery.last_attempt_date = timezone.now()
delivery.attempts = F("attempts") + 1
delivery.save(update_fields=["last_attempt_date", "attempts"])
logger.info(
f"Skipping delivery to {delivery.inbox_url} as its domain is unreachable",
)
return
actor = delivery.activity.actor
logger.info("Preparing activity delivery to %s", delivery.inbox_url)
auth = signing.get_auth(actor.private_key, actor.private_key_id)
......@@ -168,7 +185,7 @@ def deliver_to_remote(delivery):
def fetch_nodeinfo(domain_name):
s = session.get_session()
wellknown_url = "https://{}/.well-known/nodeinfo".format(domain_name)
wellknown_url = f"https://{domain_name}/.well-known/nodeinfo"
response = s.get(url=wellknown_url)
response.raise_for_status()
serializer = serializers.NodeInfoSerializer(data=response.json())
......@@ -206,7 +223,7 @@ def update_domain_nodeinfo(domain):
domain.service_actor = (
utils.retrieve_ap_object(
service_actor_id,
actor=actors.get_service_actor(),
actor=None,
queryset=models.Actor,
serializer_class=serializers.ActorSerializer,
)
......@@ -233,8 +250,10 @@ def refresh_nodeinfo_known_nodes():
settings.NODEINFO_REFRESH_DELAY
"""
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
candidates = models.Domain.objects.external().exclude(
nodeinfo_fetch_date__gte=limit
candidates = (
models.Domain.objects.external()
.exclude(nodeinfo_fetch_date__gte=limit)
.filter(nodeinfo__software__name="Funkwhale")
)
names = candidates.values_list("name", flat=True)
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))
......@@ -326,7 +345,7 @@ def fetch(fetch_obj):
auth = None
try:
if url.startswith("webfinger://"):
# we first grab the correpsonding webfinger representation
# we first grab the corresponding webfinger representation
# to get the ActivityPub actor ID
webfinger_data = webfinger.get_resource(
"acct:" + url.replace("webfinger://", "")
......@@ -338,7 +357,9 @@ def fetch(fetch_obj):
if not payload:
return error("blocked", message="Blocked by MRF")
response = session.get_session().get(
auth=auth, url=url, headers={"Accept": "application/activity+json"},
auth=auth,
url=url,
headers={"Accept": "application/activity+json"},
)
logger.debug("Remote answered with %s: %s", response.status_code, response.text)
response.raise_for_status()
......@@ -346,7 +367,7 @@ def fetch(fetch_obj):
return error(
"http",
status_code=e.response.status_code if e.response else None,
message=response.text,
message=e.response.text,
)
except requests.exceptions.Timeout:
return error("timeout")
......@@ -425,7 +446,9 @@ def fetch(fetch_obj):
# first page fetch is synchronous, so that at least some data is available
# in the UI after subscription
result = fetch_collection(
obj.actor.outbox_url, channel_id=obj.pk, max_pages=1,
obj.actor.outbox_url,
channel_id=obj.pk,
max_pages=1,
)
except Exception:
logger.exception(
......@@ -473,7 +496,8 @@ class PreserveSomeDataCollector(Collector):
@celery.app.task(name="federation.remove_actor")
@transaction.atomic
@celery.require_instance(
models.Actor.objects.all(), "actor",
models.Actor.objects.all(),
"actor",
)
def remove_actor(actor):
# Then we broadcast the info over federation. We do this *before* deleting objects
......@@ -531,7 +555,9 @@ def match_serializer(payload, conf):
@celery.app.task(name="federation.fetch_collection")
@celery.require_instance(
audio_models.Channel.objects.all(), "channel", allow_null=True,
audio_models.Channel.objects.all(),
"channel",
allow_null=True,
)
def fetch_collection(url, max_pages, channel, is_page=False):
actor = actors.get_service_actor()
......@@ -543,8 +569,8 @@ def fetch_collection(url, max_pages, channel, is_page=False):
"total": 0,
}
if is_page:
# starting immediatly from a page, no need to fetch the wrapping collection
logger.debug("Fetch collection page immediatly at %s", url)
# starting immediately from a page, no need to fetch the wrapping collection
logger.debug("Fetch collection page immediately at %s", url)
results["next_page"] = url
else:
logger.debug("Fetching collection object at %s", url)
......@@ -564,7 +590,11 @@ def fetch_collection(url, max_pages, channel, is_page=False):
for i in range(max_pages):
page_url = results["next_page"]
logger.debug("Handling page %s on max %s, at %s", i + 1, max_pages, page_url)
page = utils.retrieve_ap_object(page_url, actor=actor, serializer_class=None,)
page = utils.retrieve_ap_object(
page_url,
actor=actor,
serializer_class=None,
)
try:
items = page["orderedItems"]
except KeyError:
......@@ -614,3 +644,72 @@ def fetch_collection(url, max_pages, channel, is_page=False):
results["errored"],
)
return results
@celery.app.task(name="federation.check_all_remote_instance_availability")
def check_all_remote_instance_availability():
base_interval = 3600
factor = 1.15
for domain in models.Domain.objects.all():
if domain.name == settings.FUNKWHALE_HOSTNAME:
continue
attempt = domain.reachable_retries or 0
last_success = domain.last_successful_contact or domain.creation_date
delay_seconds = base_interval * (factor**attempt)
delay = datetime.timedelta(seconds=delay_seconds)
next_check_due = last_success + delay
now = timezone.now()
if domain.reachable is False and now < next_check_due:
logger.info(
f"[{domain.name}] Skipping check. Last successful: {last_success}, "
f"attempt #{attempt}, next check due in {delay} at {next_check_due}"
)
continue
check_single_remote_instance_availability(domain)
@celery.app.task(name="federation.check_single_remote_instance_availability")
def check_single_remote_instance_availability(domain):
try:
nodeinfo = fetch_nodeinfo(domain.name)
except Exception as e:
logger.info(
f"Domain {domain.name} could not be reached because of the following error : {e}. \
Setting domain as unreachable."
)
domain.reachable = False
domain.reachable_retries += 1
domain.save()
return domain.reachable
if "version" in nodeinfo.keys():
domain.reachable = True
domain.last_successful_contact = timezone.now()
domain.reachable_retries = 0
domain.save()
return domain.reachable
else:
logger.info(
f"Domain {domain.name} is not reachable at the moment. Setting domain as unreachable."
)
domain.reachable = False
domain.reachable_retries += 1
domain.save()
return domain.reachable
@celery.app.task(name="federation.trigger_playlist_ap_update")
def trigger_playlist_ap_update(playlist):
for playlist_uuid in cache.get("playlists_for_ap_update"):
routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Playlist"}},
context={
"playlist": playlists_models.Playlist.objects.get(uuid=playlist_uuid)
},
)
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from rest_framework import routers
from . import views
......@@ -16,13 +17,20 @@ router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"libraries", views.MusicLibraryViewSet, "libraries")
music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
music_router.register(r"artists", views.MusicArtistViewSet, "artists")
music_router.register(r"artistcredit", views.MusicArtistCreditViewSet, "artistcredit")
music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
music_router.register(r"playlists", views.PlaylistViewSet, "playlists")
music_router.register(r"playlists", views.PlaylistTrackViewSet, "playlist-tracks")
index_router.register(r"index", views.IndexViewSet, "index")
urlpatterns = router.urls + [
url("federation/music/", include((music_router.urls, "music"), namespace="music")),
url("federation/", include((index_router.urls, "index"), namespace="index")),
re_path(
"federation/music/", include((music_router.urls, "music"), namespace="music")
),
re_path("federation/", include((index_router.urls, "index"), namespace="index")),
]
import html.parser
import re
import unicodedata
import urllib.parse
import re
from django.apps import apps
from django.conf import settings
......@@ -11,8 +11,7 @@ from django.db.models import CharField, Q, Value
from funkwhale_api.common import session
from funkwhale_api.moderation import mrf
from . import exceptions
from . import signing
from . import exceptions, signing
def full_url(path):
......@@ -67,7 +66,11 @@ def slugify_username(username):
def retrieve_ap_object(
fid, actor, serializer_class=None, queryset=None, apply_instance_policies=True
fid,
actor,
serializer_class=None,
queryset=None,
apply_instance_policies=True,
):
# we have a duplicate check here because it's less expensive to do those checks
# twice than to trigger a HTTP request
......@@ -119,10 +122,8 @@ def get_domain_query_from_url(domain, url_field="fid"):
to match objects that have this domain in the given field.
"""
query = Q(**{"{}__startswith".format(url_field): "http://{}/".format(domain)})
query = query | Q(
**{"{}__startswith".format(url_field): "https://{}/".format(domain)}
)
query = Q(**{f"{url_field}__startswith": f"http://{domain}/"})
query = query | Q(**{f"{url_field}__startswith": f"https://{domain}/"})
return query
......@@ -135,18 +136,15 @@ def local_qs(queryset, url_field="fid", include=True):
return queryset.filter(query)
def is_local(url):
def is_local(url) -> bool:
if not url:
return True
d = settings.FEDERATION_HOSTNAME
return url.startswith("http://{}/".format(d)) or url.startswith(
"https://{}/".format(d)
)
return url.startswith(f"http://{d}/") or url.startswith(f"https://{d}/")
def get_actor_data_from_username(username):
parts = username.split("@")
return {
......@@ -161,8 +159,8 @@ def get_actor_from_username_data_query(field, data):
if field:
return Q(
**{
"{}__preferred_username__iexact".format(field): data["username"],
"{}__domain__name__iexact".format(field): data["domain"],
f"{field}__preferred_username__iexact": data["username"],
f"{field}__domain__name__iexact": data["domain"],
}
)
else:
......@@ -244,8 +242,7 @@ FID_MODEL_LABELS = [
def get_object_by_fid(fid, local=None):
if local is True:
if local:
parsed = urllib.parse.urlparse(fid)
if parsed.netloc != settings.FEDERATION_HOSTNAME:
raise ObjectDoesNotExist()
......@@ -291,3 +288,9 @@ def can_manage(obj_owner, actor):
return True
return False
def update_actor_privacy(actor, privacy_level):
actor.track_favorites.update(privacy_level=privacy_level)
actor.listenings.update(privacy_level=privacy_level)
# to do : trigger federation privacy_level downgrade #2336
......@@ -7,16 +7,20 @@ from django.urls import reverse
from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils
from funkwhale_api.playlists import models as playlists_models
from . import (
actors,
activity,
actors,
authentication,
models,
renderers,
......@@ -68,7 +72,7 @@ class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
return bool(request.actor)
class FederationMixin(object):
class FederationMixin:
permission_classes = [AuthenticatedIfAllowListEnabled]
def dispatch(self, request, *args, **kwargs):
......@@ -161,7 +165,9 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
"actor": channel.actor,
"items": channel.library.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related("library__channel__actor", "track__artist"),
.prefetch_related(
"library__channel__actor", "track__artist_credit__artist"
),
"item_serializer": serializers.ChannelCreateUploadSerializer,
}
return get_collection_response(
......@@ -170,17 +176,115 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
collection_serializer=serializers.ChannelOutboxSerializer(channel),
)
@action(methods=["get"], detail=True)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def followers(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followers = list(actor.get_approved_followers())
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followers,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(methods=["get"], detail=True)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def following(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followings = list(
actor.emitted_follows.filter(approved=True).values_list("target", flat=True)
)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followings,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def listens(self, request, *args, **kwargs):
actor = self.get_object()
listenings = history_models.Listening.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-listens",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": listenings,
"item_serializer": serializers.ListeningSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def likes(self, request, *args, **kwargs):
actor = self.get_object()
likes = favorites_models.TrackFavorite.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-likes",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": likes,
"item_serializer": serializers.TrackFavoriteSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
......@@ -223,9 +327,9 @@ class WellKnownViewSet(viewsets.GenericViewSet):
return HttpResponse(status=405)
try:
resource_type, resource = webfinger.clean_resource(request.GET["resource"])
cleaner = getattr(webfinger, "clean_{}".format(resource_type))
cleaner = getattr(webfinger, f"clean_{resource_type}")
result = cleaner(resource)
handler = getattr(self, "handler_{}".format(resource_type))
handler = getattr(self, f"handler_{resource_type}")
data = handler(result)
except forms.ValidationError as e:
return response.Response({"errors": {"resource": e.message}}, status=400)
......@@ -261,6 +365,20 @@ def has_library_access(request, library):
return library.received_follows.filter(actor=actor, approved=True).exists()
def has_playlist_access(request, playlist):
if playlist.privacy_level == "everyone":
return True
if request.user.is_authenticated and request.user.is_superuser:
return True
try:
actor = request.actor
except AttributeError:
return False
return playlist.library.received_follows.filter(actor=actor, approved=True).exists()
class MusicLibraryViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
......@@ -279,39 +397,41 @@ class MusicLibraryViewSet(
lb = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(lb.get_absolute_url())
items_qs = (
lb.uploads.for_federation()
if not lb.playlist_uploads.all()
else lb.playlist_uploads.for_federation()
)
conf = {
"id": lb.get_federation_id(),
"actor": lb.actor,
"name": lb.name,
"summary": lb.description,
"items": lb.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related(
"items": items_qs.order_by("-creation_date").prefetch_related(
Prefetch(
"track",
queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to",
"artist__attributed_to",
"artist__attachment_cover",
"attachment_cover",
"album__attributed_to",
"attributed_to",
"album__attachment_cover",
"album__artist__attachment_cover",
"description",
).prefetch_related(
"album__artist_credit__artist__attributed_to",
"artist_credit__artist__attributed_to",
"artist_credit__artist__attachment_cover",
"tagged_items__tag",
"album__tagged_items__tag",
"album__artist__tagged_items__tag",
"artist__tagged_items__tag",
"artist__description",
"album__artist_credit__artist__tagged_items__tag",
"album__artist_credit__artist__attachment_cover",
"artist_credit__artist__tagged_items__tag",
"artist_credit__artist__description",
"album__description",
),
)
),
"item_serializer": serializers.UploadSerializer,
"library": lb,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
......@@ -331,16 +451,21 @@ class MusicUploadViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related(
queryset = (
music_models.Upload.objects.local()
.select_related(
"library__actor",
"track__artist",
"track__album__artist",
"track__description",
"track__album__attachment_cover",
"track__album__artist__attachment_cover",
"track__artist__attachment_cover",
"track__attachment_cover",
)
.prefetch_related(
"track__artist_credit__artist",
"track__album__artist_credit__artist",
"track__album__artist_credit__artist__attachment_cover",
"track__artist_credit__artist__attachment_cover",
)
)
serializer_class = serializers.UploadSerializer
lookup_field = "uuid"
......@@ -393,13 +518,35 @@ class MusicArtistViewSet(
return response.Response(serializer.data)
class MusicArtistCreditViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.ArtistCredit.objects.local().prefetch_related("artist")
serializer_class = serializers.ArtistCreditSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class MusicAlbumViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related(
"artist__description", "description", "artist__attachment_cover"
queryset = (
music_models.Album.objects.local()
.prefetch_related(
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
)
.select_related(
"description",
)
)
serializer_class = serializers.AlbumSerializer
lookup_field = "uuid"
......@@ -418,16 +565,22 @@ class MusicTrackViewSet(
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related(
"album__artist",
queryset = (
music_models.Track.objects.local()
.select_related(
"album__description",
"artist__description",
"description",
"attachment_cover",
"album__artist__attachment_cover",
"album__attachment_cover",
"artist__attachment_cover",
)
.prefetch_related(
"album__artist_credit__artist",
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
"album__artist_credit__artist__attachment_cover",
)
)
serializer_class = serializers.TrackSerializer
lookup_field = "uuid"
......@@ -470,7 +623,8 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
return super().dispatch(request, *args, **kwargs)
@action(
methods=["get"], detail=False,
methods=["get"],
detail=False,
)
def libraries(self, request, *args, **kwargs):
libraries = (
......@@ -497,7 +651,8 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response({}, status=200)
@action(
methods=["get"], detail=False,
methods=["get"],
detail=False,
)
def channels(self, request, *args, **kwargs):
actors = (
......@@ -525,3 +680,95 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
)
return response.Response({}, status=200)
class TrackFavoriteViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = favorites_models.TrackFavorite.objects.local().select_related(
"track", "actor"
)
serializer_class = serializers.TrackFavoriteSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ListeningsViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = history_models.Listening.objects.local().select_related("track", "actor")
serializer_class = serializers.ListeningSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class PlaylistViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.Playlist.objects.local().select_related("actor")
serializer_class = serializers.PlaylistCollectionSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
playlist = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(playlist.get_absolute_url())
conf = {
"id": playlist.fid,
"actor": playlist.actor,
"name": playlist.name,
"items": playlist.playlist_tracks.order_by("index").prefetch_related(
"track",
),
"item_serializer": serializers.PlaylistTrackSerializer,
"library": playlist.library.fid,
}
return get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.PlaylistCollectionSerializer(playlist),
page_access_check=lambda: has_playlist_access(request, playlist),
)
class PlaylistTrackViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers()
queryset = playlists_models.PlaylistTrack.objects.local().select_related("actor")
serializer_class = serializers.PlaylistTrackSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
plt = self.get_object()
if not has_playlist_access(request, plt.playlist):
return response.Response(status=403)
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(plt.get_absolute_url())
serializer = self.get_serializer(plt)
return response.Response(serializer.data)
......@@ -30,7 +30,7 @@ def clean_acct(acct_string, ensure_local=True):
raise forms.ValidationError("Invalid format")
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError("Invalid hostname {}".format(hostname))
raise forms.ValidationError(f"Invalid hostname {hostname}")
return username, hostname
......
......@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer)
@record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(
......
......@@ -5,6 +5,6 @@ from . import models
@admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin):
list_display = ["track", "creation_date", "user", "session_key"]
search_fields = ["track__name", "user__username"]
list_select_related = ["user", "track"]
list_display = ["track", "creation_date", "actor", "session_key"]
search_fields = ["track__name", "actor__user__username"]
list_select_related = ["actor", "track"]
import factory
from django.conf import settings
from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music import factories
from funkwhale_api.users.factories import UserFactory
@registry.register
class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
actor = factory.SubFactory(ActorFactory)
track = factory.SubFactory(factories.TrackFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
privacy_level = "everyone"
class Meta:
model = "history.Listening"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])
......@@ -7,9 +7,9 @@ from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet):
username = django_filters.CharFilter("user__username")
domain = django_filters.CharFilter("user__actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True)
username = django_filters.CharFilter("actor__user__username")
domain = django_filters.CharFilter("actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta:
model = models.Listening
......
# Generated by Django 3.2.20 on 2023-12-09 14:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('history', '0002_auto_20180325_1433'),
]
operations = [
migrations.AddField(
model_name='listening',
name='source',
field=models.CharField(blank=True, max_length=100, null=True),
),
]
import uuid
from django.db import migrations, models
from django.urls import reverse
from funkwhale_api.federation import utils
import django.db.models.deletion
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:listenings-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
row.save(update_fields=["uuid", "fid"])
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("history", "0003_listening_source"),
("federation", "0028_auto_20221027_1141"),
]
operations = [
migrations.AddField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
migrations.AddField(
model_name="listening",
name="fid",
field=models.URLField(
max_length=500,
null=True,
),
),
migrations.AddField(
model_name="listening",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
migrations.AlterField(
model_name="listening",
name="fid",
field=models.URLField(
unique=True,
db_index=True,
max_length=500,
),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="listening",
name="user",
),
migrations.AlterField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
]
# Generated by Django 5.1.6 on 2025-09-12 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("history", "0004_listening_actor_listening_fid_listening_url"),
]
operations = [
migrations.AddField(
model_name="listening",
name="privacy_level",
field=models.CharField(
choices=[
("me", "Only me"),
("followers", "Me and my followers"),
("instance", "Everyone on my instance, and my followers"),
("everyone", "Everyone, including people on other instances"),
],
max_length=30,
default="me",
),
),
]
import uuid
from django.db import models
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track
class Listening(models.Model):
class ListeningQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
pass
class Listening(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE
)
user = models.ForeignKey(
"users.User",
actor = models.ForeignKey(
"federation.Actor",
related_name="listenings",
null=True,
blank=True,
on_delete=models.CASCADE,
null=False,
blank=False,
)
privacy_level = fields.get_privacy_field()
session_key = models.CharField(max_length=100, null=True, blank=True)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "listenings"
objects = ListeningQuerySet.as_manager()
class Meta:
ordering = ("-creation_date",)
def get_activity_url(self):
return "{}/listenings/tracks/{}".format(self.user.get_activity_url(), self.pk)
return f"{self.actor.get_absolute_url()}/listenings/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
if not self.privacy_level:
self.privacy_level = self.actor.user.privacy_level
return super().save(**kwargs)
......@@ -3,7 +3,6 @@ from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models
......@@ -11,46 +10,39 @@ from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user")
actor = federation_serializers.APIActorSerializer()
published = serializers.DateTimeField(source="creation_date")
class Meta:
model = models.Listening
fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return "Listen"
class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date", "actor")
fields = ("id", "actor", "track", "creation_date", "actor")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class ListeningWriteSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True, required=False)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date")
fields = ("id", "actor", "track", "creation_date")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)
from rest_framework import mixins, viewsets
from django.db.models import Prefetch
from rest_framework import mixins, viewsets
from config import plugins
from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track
from funkwhale_api.federation import routes
from funkwhale_api.music import utils as music_utils
from . import filters, models, serializers
from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
class ListeningViewSet(
mixins.CreateModelMixin,
......@@ -19,11 +18,8 @@ class ListeningViewSet(
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related(
"user__actor__attachment_icon"
)
queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
permission_classes = [
oauth_permissions.ScopePermission,
......@@ -32,6 +28,7 @@ class ListeningViewSet(
required_scope = "listenings"
anonymous_policy = "setting"
owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter
def get_serializer_class(self):
......@@ -41,23 +38,40 @@ class ListeningViewSet(
def perform_create(self, serializer):
r = super().perform_create(serializer)
instance = serializer.instance
plugins.trigger_hook(
plugins.LISTENING_CREATED,
listening=serializer.instance,
listening=instance,
confs=plugins.get_confs(self.request.user),
)
routes.outbox.dispatch(
{"type": "Listen", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
record.send(serializer.instance)
return r
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
fields.privacy_level_query(
self.request.user, "privacy_level", "actor__user"
)
)
tracks = Track.objects.with_playable_uploads(
tracks = (
Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
).select_related(
"artist", "album__artist", "attributed_to", "artist__attachment_cover"
)
.prefetch_related(
"artist_credit",
"album__artist_credit__artist",
"artist_credit__artist__attachment_cover",
)
.select_related("attributed_to")
)
return queryset.prefetch_related(Prefetch("track", queryset=tracks))
......
from django.forms import widgets
import pycountry
from django.core.validators import FileExtensionValidator
from django.forms import widgets
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
......@@ -171,3 +171,18 @@ class Banner(ImagePreference):
default = None
help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended."
field_kwargs = {"required": False}
@global_preferences_registry.register
class Location(types.ChoicePreference):
show_in_api = True
section = instance
name = "location"
verbose_name = "Server Location"
default = ""
choices = [(country.alpha_2, country.name) for country in pycountry.countries]
help_text = (
"The country or territory in which your server is located. This is displayed in the server's Nodeinfo "
"endpoint."
)
field_kwargs = {"choices": choices, "required": False}
import memoize.djangocache
from django.urls import reverse
import funkwhale_api
from funkwhale_api.common import preferences
from funkwhale_api.federation import actors, models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import utils as music_utils
from . import stats
store = memoize.djangocache.Cache("default")
memo = memoize.Memoizer(store, namespace="instance:stats")
def get():
all_preferences = preferences.all()
share_stats = all_preferences.get("instance__nodeinfo_stats_enabled")
allow_list_enabled = all_preferences.get("moderation__allow_list_enabled")
allow_list_public = all_preferences.get("moderation__allow_list_public")
auth_required = all_preferences.get("common__api_authentication_required")
banner = all_preferences.get("instance__banner")
unauthenticated_report_types = all_preferences.get(
"moderation__unauthenticated_report_types"
)
if allow_list_enabled and allow_list_public:
allowed_domains = list(
federation_models.Domain.objects.filter(allowed=True)
.order_by("name")
.values_list("name", flat=True)
)
else:
allowed_domains = None
data = {
"version": "2.0",
"software": {"name": "funkwhale", "version": funkwhale_api.__version__},
"protocols": ["activitypub"],
"services": {"inbound": [], "outbound": []},
"openRegistrations": all_preferences.get("users__registration_enabled"),
"usage": {"users": {"total": 0, "activeHalfyear": 0, "activeMonth": 0}},
"metadata": {
"actorId": actors.get_service_actor().fid,
"private": all_preferences.get("instance__nodeinfo_private"),
"shortDescription": all_preferences.get("instance__short_description"),
"longDescription": all_preferences.get("instance__long_description"),
"rules": all_preferences.get("instance__rules"),
"contactEmail": all_preferences.get("instance__contact_email"),
"terms": all_preferences.get("instance__terms"),
"nodeName": all_preferences.get("instance__name"),
"banner": federation_utils.full_url(banner.url) if banner else None,
"defaultUploadQuota": all_preferences.get("users__upload_quota"),
"library": {
"federationEnabled": all_preferences.get("federation__enabled"),
"anonymousCanListen": not all_preferences.get(
"common__api_authentication_required"
),
},
"supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS,
"allowList": {"enabled": allow_list_enabled, "domains": allowed_domains},
"reportTypes": [
{"type": t, "label": l, "anonymous": t in unauthenticated_report_types}
for t, l in moderation_models.REPORT_TYPES
],
"funkwhaleSupportMessageEnabled": all_preferences.get(
"instance__funkwhale_support_message_enabled"
),
"instanceSupportMessage": all_preferences.get("instance__support_message"),
"endpoints": {"knownNodes": None, "channels": None, "libraries": None},
},
}
if share_stats:
getter = memo(lambda: stats.get(), max_age=600)
statistics = getter()
data["usage"]["users"]["total"] = statistics["users"]["total"]
data["usage"]["users"]["activeHalfyear"] = statistics["users"][
"active_halfyear"
]
data["usage"]["users"]["activeMonth"] = statistics["users"]["active_month"]
data["metadata"]["library"]["tracks"] = {"total": statistics["tracks"]}
data["metadata"]["library"]["artists"] = {"total": statistics["artists"]}
data["metadata"]["library"]["albums"] = {"total": statistics["albums"]}
data["metadata"]["library"]["music"] = {"hours": statistics["music_duration"]}
data["metadata"]["usage"] = {
"favorites": {"tracks": {"total": statistics["track_favorites"]}},
"listenings": {"total": statistics["listenings"]},
"downloads": {"total": statistics["downloads"]},
}
if not auth_required:
data["metadata"]["endpoints"]["knownNodes"] = federation_utils.full_url(
reverse("api:v1:federation:domains-list")
)
if not auth_required and preferences.get("federation__public_index"):
data["metadata"]["endpoints"]["libraries"] = federation_utils.full_url(
reverse("federation:index:index-libraries")
)
data["metadata"]["endpoints"]["channels"] = federation_utils.full_url(
reverse("federation:index:index-channels")
)
return data