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
  • 2448-complete-tags
  • 2451-delete-no-user-query
  • 2452-fetch-third-party-metadata
  • 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
  • fix-amd64-docker-build-gfortran
  • 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
  • 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
  • wip/2091-improve-visuals
  • wvffle/dependency-maintenance
  • wvffle/new-upload-process
  • wvffle/ui-rewrite-sidebar
  • wvffle/ui-sidebar
  • 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
190 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
  • 1.0.1
  • 1108-remove-jwt-and-switch-to-oauth-for-ui-auth
  • 1121-download
  • 1278-embed-isn-t-available-in-the-front-end-for-channel-tracks
  • 1299-encoding-problem-in-rss-feeds
  • 1303-failing-to-refetch-federated-tracks
  • 1311-feedparser-requires-update-to-accomodate-python-3-9
  • 1346-selectoreventloop-required-instead-got-uvloop-loop
  • 1356-update-packages
  • develop
  • master
  • plugins
  • plugins-v2
  • plugins-v3
  • set-sast-config-1
  • set-sast-config-2
  • tracemallocmiddleware
  • 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
79 results
Show changes
Showing
with 1294 additions and 418 deletions
import logging import logging
import os import os
import re
import urllib.parse import urllib.parse
import uuid import uuid
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework import serializers 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 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 models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals from funkwhale_api.moderation import signals as moderation_signals
from funkwhale_api.music import licenses from funkwhale_api.music import licenses
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks 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 funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -116,7 +120,7 @@ class MediaSerializer(jsonld.JsonLdSerializer): ...@@ -116,7 +120,7 @@ class MediaSerializer(jsonld.JsonLdSerializer):
if not is_mimetype(v, self.allowed_mimetypes): if not is_mimetype(v, self.allowed_mimetypes):
raise serializers.ValidationError( raise serializers.ValidationError(
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes) f"Invalid mimetype {v}. Allowed: {self.allowed_mimetypes}"
) )
return v return v
...@@ -237,7 +241,9 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -237,7 +241,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES] choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
) )
preferredUsername = serializers.CharField() preferredUsername = serializers.CharField()
manuallyApprovesFollowers = serializers.NullBooleanField(required=False) manuallyApprovesFollowers = serializers.BooleanField(
required=False, allow_null=True
)
name = serializers.CharField( name = serializers.CharField(
required=False, max_length=200, allow_blank=True, allow_null=True required=False, max_length=200, allow_blank=True, allow_null=True
) )
...@@ -245,6 +251,7 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -245,6 +251,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH, truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
required=False, required=False,
allow_null=True, allow_null=True,
allow_blank=True,
) )
followers = serializers.URLField(max_length=500, required=False) followers = serializers.URLField(max_length=500, required=False)
following = serializers.URLField(max_length=500, required=False, allow_null=True) following = serializers.URLField(max_length=500, required=False, allow_null=True)
...@@ -337,9 +344,11 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -337,9 +344,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["url"] = [ ret["url"] = [
{ {
"type": "Link", "type": "Link",
"href": instance.channel.get_absolute_url() "href": (
instance.channel.get_absolute_url()
if instance.channel.artist.is_local if instance.channel.artist.is_local
else instance.get_absolute_url(), else instance.get_absolute_url()
),
"mediaType": "text/html", "mediaType": "text/html",
}, },
{ {
...@@ -369,7 +378,7 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -369,7 +378,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["publicKey"] = { ret["publicKey"] = {
"owner": instance.fid, "owner": instance.fid,
"publicKeyPem": instance.public_key, "publicKeyPem": instance.public_key,
"id": "{}#main-key".format(instance.fid), "id": f"{instance.fid}#main-key",
} }
ret["endpoints"] = {} ret["endpoints"] = {}
...@@ -433,9 +442,11 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -433,9 +442,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file( common_utils.attach_file(
actor, actor,
"attachment_icon", "attachment_icon",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")} {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value if new_value
else None, else None
),
) )
rss_url = get_by_media_type( rss_url = get_by_media_type(
...@@ -451,7 +462,7 @@ class ActorSerializer(jsonld.JsonLdSerializer): ...@@ -451,7 +462,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
actor, actor,
rss_url=rss_url, rss_url=rss_url,
attributed_to_fid=attributed_to, attributed_to_fid=attributed_to,
**self.validated_data **self.validated_data,
) )
return actor return actor
...@@ -488,9 +499,11 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data ...@@ -488,9 +499,11 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
common_utils.attach_file( common_utils.attach_file(
artist, artist,
"attachment_cover", "attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")} {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value if new_value
else None, else None
),
) )
tags = [t["name"] for t in validated_data.get("tags", []) or []] tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(artist, *tags) tags_models.set_tags(artist, *tags)
...@@ -500,7 +513,10 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data ...@@ -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}) reverse("federation:music:libraries-detail", kwargs={"uuid": uid})
) )
library = attributed_to.libraries.create( 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: else:
library = artist.channel.library library = artist.channel.library
...@@ -512,7 +528,9 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data ...@@ -512,7 +528,9 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
"library": library, "library": library,
} }
channel, created = audio_models.Channel.objects.update_or_create( 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 return channel
...@@ -636,7 +654,6 @@ class FollowSerializer(serializers.Serializer): ...@@ -636,7 +654,6 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs): def save(self, **kwargs):
target = self.validated_data["object"] target = self.validated_data["object"]
if target._meta.label == "music.Library": if target._meta.label == "music.Library":
follow_class = models.LibraryFollow follow_class = models.LibraryFollow
else: else:
...@@ -729,9 +746,7 @@ class FollowActionSerializer(serializers.Serializer): ...@@ -729,9 +746,7 @@ class FollowActionSerializer(serializers.Serializer):
.get() .get()
) )
except follow_class.DoesNotExist: except follow_class.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(f"No follow to {self.action_type}")
"No follow to {}".format(self.action_type)
)
return validated_data return validated_data
def to_representation(self, instance): def to_representation(self, instance):
...@@ -742,7 +757,7 @@ class FollowActionSerializer(serializers.Serializer): ...@@ -742,7 +757,7 @@ class FollowActionSerializer(serializers.Serializer):
return { return {
"@context": jsonld.get_default_context(), "@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(), "type": self.action_type.title(),
"actor": actor.fid, "actor": actor.fid,
"object": FollowSerializer(instance).data, "object": FollowSerializer(instance).data,
...@@ -750,7 +765,6 @@ class FollowActionSerializer(serializers.Serializer): ...@@ -750,7 +765,6 @@ class FollowActionSerializer(serializers.Serializer):
class AcceptFollowSerializer(FollowActionSerializer): class AcceptFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Accept"]) type = serializers.ChoiceField(choices=["Accept"])
action_type = "accept" action_type = "accept"
...@@ -764,7 +778,6 @@ class AcceptFollowSerializer(FollowActionSerializer): ...@@ -764,7 +778,6 @@ class AcceptFollowSerializer(FollowActionSerializer):
class RejectFollowSerializer(FollowActionSerializer): class RejectFollowSerializer(FollowActionSerializer):
type = serializers.ChoiceField(choices=["Reject"]) type = serializers.ChoiceField(choices=["Reject"])
action_type = "reject" action_type = "reject"
...@@ -808,7 +821,9 @@ class UndoFollowSerializer(serializers.Serializer): ...@@ -808,7 +821,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target actor=validated_data["actor"], target=target
).get() ).get()
except follow_class.DoesNotExist: 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 return validated_data
def to_representation(self, instance): def to_representation(self, instance):
...@@ -848,7 +863,7 @@ class ActorWebfingerSerializer(serializers.Serializer): ...@@ -848,7 +863,7 @@ class ActorWebfingerSerializer(serializers.Serializer):
def to_representation(self, instance): def to_representation(self, instance):
data = {} data = {}
data["subject"] = "acct:{}".format(instance.webfinger_subject) data["subject"] = f"acct:{instance.webfinger_subject}"
data["links"] = [ data["links"] = [
{"rel": "self", "href": instance.fid, "type": "application/activity+json"} {"rel": "self", "href": instance.fid, "type": "application/activity+json"}
] ]
...@@ -874,8 +889,7 @@ class ActivitySerializer(serializers.Serializer): ...@@ -874,8 +889,7 @@ class ActivitySerializer(serializers.Serializer):
try: try:
object_serializer = OBJECT_SERIALIZERS[type] object_serializer = OBJECT_SERIALIZERS[type]
except KeyError: except KeyError:
raise serializers.ValidationError("Unsupported type {}".format(type)) raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value) serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return serializer.data return serializer.data
...@@ -926,10 +940,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES} ...@@ -926,10 +940,13 @@ OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
def get_additional_fields(data): def get_additional_fields(data):
UNSET = object() UNSET = object()
additional_fields = {} additional_fields = {}
for field in ["name", "summary"]: for field in ["name", "summary", "library", "audience", "published"]:
v = data.get(field, UNSET) v = data.get(field, UNSET)
if v == UNSET: if v == UNSET:
continue 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 additional_fields[field] = v
return additional_fields return additional_fields
...@@ -960,7 +977,7 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer): ...@@ -960,7 +977,7 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
first = common_utils.set_query_parameter(conf["id"], page=1) first = common_utils.set_query_parameter(conf["id"], page=1)
current = first current = first
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages) last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
d = { data = {
"id": conf["id"], "id": conf["id"],
"attributedTo": conf["actor"].fid, "attributedTo": conf["actor"].fid,
"totalItems": paginator.count, "totalItems": paginator.count,
...@@ -969,10 +986,10 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer): ...@@ -969,10 +986,10 @@ class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
"first": first, "first": first,
"last": last, "last": last,
} }
d.update(get_additional_fields(conf)) data.update(get_additional_fields(conf))
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
class LibrarySerializer(PaginatedCollectionSerializer): class LibrarySerializer(PaginatedCollectionSerializer):
...@@ -982,8 +999,6 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -982,8 +999,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
actor = serializers.URLField(max_length=500, required=False) actor = serializers.URLField(max_length=500, required=False)
attributedTo = serializers.URLField(max_length=500, required=False) attributedTo = serializers.URLField(max_length=500, required=False)
name = serializers.CharField() name = serializers.CharField()
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
followers = serializers.URLField(max_length=500)
audience = serializers.ChoiceField( audience = serializers.ChoiceField(
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"], choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
required=False, required=False,
...@@ -1000,9 +1015,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -1000,9 +1015,7 @@ class LibrarySerializer(PaginatedCollectionSerializer):
PAGINATED_COLLECTION_JSONLD_MAPPING, PAGINATED_COLLECTION_JSONLD_MAPPING,
{ {
"name": jsonld.first_val(contexts.AS.name), "name": jsonld.first_val(contexts.AS.name),
"summary": jsonld.first_val(contexts.AS.summary),
"audience": jsonld.first_id(contexts.AS.audience), "audience": jsonld.first_id(contexts.AS.audience),
"followers": jsonld.first_id(contexts.AS.followers),
"actor": jsonld.first_id(contexts.AS.actor), "actor": jsonld.first_id(contexts.AS.actor),
"attributedTo": jsonld.first_id(contexts.AS.attributedTo), "attributedTo": jsonld.first_id(contexts.AS.attributedTo),
}, },
...@@ -1024,18 +1037,20 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -1024,18 +1037,20 @@ class LibrarySerializer(PaginatedCollectionSerializer):
conf = { conf = {
"id": library.fid, "id": library.fid,
"name": library.name, "name": library.name,
"summary": library.description,
"page_size": 100, "page_size": 100,
"attributedTo": library.actor, "attributedTo": library.actor,
"actor": 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", "type": "Library",
} }
r = super().to_representation(conf) r = super().to_representation(conf)
r["audience"] = ( r["audience"] = (
contexts.AS.Public if library.privacy_level == "everyone" else "" contexts.AS.Public if library.privacy_level == "everyone" else ""
) )
r["followers"] = library.followers_url
return r return r
def create(self, validated_data): def create(self, validated_data):
...@@ -1055,8 +1070,6 @@ class LibrarySerializer(PaginatedCollectionSerializer): ...@@ -1055,8 +1070,6 @@ class LibrarySerializer(PaginatedCollectionSerializer):
defaults={ defaults={
"uploads_count": validated_data["totalItems"], "uploads_count": validated_data["totalItems"],
"name": validated_data["name"], "name": validated_data["name"],
"description": validated_data.get("summary"),
"followers_url": validated_data["followers"],
"privacy_level": privacy[validated_data["audience"]], "privacy_level": privacy[validated_data["audience"]],
}, },
) )
...@@ -1123,7 +1136,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer): ...@@ -1123,7 +1136,12 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
"last": last, "last": last,
"items": [ "items": [
conf["item_serializer"]( 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 ).data
for i in page.object_list for i in page.object_list
], ],
...@@ -1158,7 +1176,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = { ...@@ -1158,7 +1176,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
def repr_tag(tag_name): 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): def include_content(repr, content_obj):
...@@ -1217,12 +1235,22 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer): ...@@ -1217,12 +1235,22 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
self.updateable_fields, validated_data, instance self.updateable_fields, validated_data, instance
) )
updated_fields = self.validate_updated_data(instance, updated_fields) 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: if creating:
instance, created = self.Meta.model.objects.get_or_create( instance, created = self.Meta.model.objects.get_or_create(
fid=validated_data["id"], defaults=updated_fields fid=validated_data["id"], defaults=updated_fields
) )
if set_ac:
instance.artist_credit.set(artist_credit)
else: 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 = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(instance, *tags) tags_models.set_tags(instance, *tags)
...@@ -1284,7 +1312,6 @@ class ArtistSerializer(MusicEntitySerializer): ...@@ -1284,7 +1312,6 @@ class ArtistSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"released": jsonld.first_val(contexts.FW.released), "released": jsonld.first_val(contexts.FW.released),
"artists": jsonld.first_attr(contexts.FW.artists, "@list"),
"image": jsonld.first_obj(contexts.AS.image), "image": jsonld.first_obj(contexts.AS.image),
}, },
) )
...@@ -1296,9 +1323,9 @@ class ArtistSerializer(MusicEntitySerializer): ...@@ -1296,9 +1323,9 @@ class ArtistSerializer(MusicEntitySerializer):
"name": instance.name, "name": instance.name,
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"attributedTo": instance.attributed_to.fid "attributedTo": (
if instance.attributed_to instance.attributed_to.fid if instance.attributed_to else None
else None, ),
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
include_content(d, instance.description) include_content(d, instance.description)
...@@ -1310,12 +1337,53 @@ class ArtistSerializer(MusicEntitySerializer): ...@@ -1310,12 +1337,53 @@ class ArtistSerializer(MusicEntitySerializer):
create = MusicEntitySerializer.update_or_create 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): class AlbumSerializer(MusicEntitySerializer):
released = serializers.DateField(allow_null=True, required=False) released = serializers.DateField(allow_null=True, required=False)
artists = serializers.ListField( artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
child=MultipleSerializer(allowed=[BasicActorSerializer, ArtistSerializer]),
min_length=1,
)
image = ImageSerializer( image = ImageSerializer(
allowed_mimetypes=["image/*"], allowed_mimetypes=["image/*"],
allow_null=True, allow_null=True,
...@@ -1328,7 +1396,7 @@ class AlbumSerializer(MusicEntitySerializer): ...@@ -1328,7 +1396,7 @@ class AlbumSerializer(MusicEntitySerializer):
("musicbrainzId", "mbid"), ("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"), ("attributedTo", "attributed_to"),
("released", "release_date"), ("released", "release_date"),
("_artist", "artist"), ("artist_credit", "artist_credit"),
] ]
class Meta: class Meta:
...@@ -1337,62 +1405,60 @@ class AlbumSerializer(MusicEntitySerializer): ...@@ -1337,62 +1405,60 @@ class AlbumSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"released": jsonld.first_val(contexts.FW.released), "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), "image": jsonld.first_obj(contexts.AS.image),
}, },
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { data = {
"type": "Album", "type": "Album",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.title,
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": instance.release_date.isoformat() "released": (
if instance.release_date instance.release_date.isoformat() if instance.release_date else None
else None, ),
"attributedTo": instance.attributed_to.fid "attributedTo": (
if instance.attributed_to instance.attributed_to.fid if instance.attributed_to else None
else None, ),
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
if instance.artist.get_channel():
d["artists"] = [ data["artist_credit"] = ArtistCreditSerializer(
{ instance.artist_credit.all(),
"type": instance.artist.channel.actor.type, context={"include_ap_context": False},
"id": instance.artist.channel.actor.fid, many=True,
}
]
else:
d["artists"] = [
ArtistSerializer(
instance.artist, context={"include_ap_context": False}
).data ).data
] include_content(data, instance.description)
include_content(d, instance.description)
if instance.attachment_cover: 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): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
def validate(self, data): def validate(self, data):
validated_data = super().validate(data) validated_data = super().validate(data)
if not self.parent: if not self.parent:
artist_data = validated_data["artists"][0] artist_credit_data = validated_data["artist_credit"]
if artist_data.get("type", "Artist") == "Artist": if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
validated_data["_artist"] = utils.retrieve_ap_object( acs = []
artist_data["id"], for ac in validated_data["artist_credit"]:
acs.append(
utils.retrieve_ap_object(
ac["id"],
actor=self.context.get("fetch_actor"), actor=self.context.get("fetch_actor"),
queryset=music_models.Artist, queryset=music_models.ArtistCredit,
serializer_class=ArtistSerializer, serializer_class=ArtistCreditSerializer,
) )
)
validated_data["artist_credit"] = acs
else: else:
# we have an actor as an artist, so it's a channel # we have an actor as an artist, so it's a channel
actor = actors.get_actor(artist_data["id"]) actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
validated_data["_artist"] = actor.channel.artist validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
return validated_data return validated_data
...@@ -1402,7 +1468,7 @@ class AlbumSerializer(MusicEntitySerializer): ...@@ -1402,7 +1468,7 @@ class AlbumSerializer(MusicEntitySerializer):
class TrackSerializer(MusicEntitySerializer): class TrackSerializer(MusicEntitySerializer):
position = serializers.IntegerField(min_value=0, allow_null=True, required=False) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, 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() album = AlbumSerializer()
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
copyright = serializers.CharField(allow_null=True, required=False) copyright = serializers.CharField(allow_null=True, required=False)
...@@ -1430,7 +1496,7 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1430,7 +1496,7 @@ class TrackSerializer(MusicEntitySerializer):
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
{ {
"album": jsonld.first_obj(contexts.FW.album), "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), "copyright": jsonld.first_val(contexts.FW.copyright),
"disc": jsonld.first_val(contexts.FW.disc), "disc": jsonld.first_val(contexts.FW.disc),
"license": jsonld.first_id(contexts.FW.license), "license": jsonld.first_id(contexts.FW.license),
...@@ -1440,7 +1506,7 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1440,7 +1506,7 @@ class TrackSerializer(MusicEntitySerializer):
) )
def to_representation(self, instance): def to_representation(self, instance):
d = { data = {
"type": "Track", "type": "Track",
"id": instance.fid, "id": instance.fid,
"name": instance.title, "name": instance.title,
...@@ -1448,29 +1514,32 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1448,29 +1514,32 @@ class TrackSerializer(MusicEntitySerializer):
"musicbrainzId": str(instance.mbid) if instance.mbid else None, "musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position, "position": instance.position,
"disc": instance.disc_number, "disc": instance.disc_number,
"license": instance.local_license["identifiers"][0] "license": (
instance.local_license["identifiers"][0]
if instance.local_license if instance.local_license
else None, else None
),
"copyright": instance.copyright if instance.copyright else None, "copyright": instance.copyright if instance.copyright else None,
"artists": [ "artist_credit": ArtistCreditSerializer(
ArtistSerializer( instance.artist_credit.all(),
instance.artist, context={"include_ap_context": False} context={"include_ap_context": False},
).data many=True,
], ).data,
"album": AlbumSerializer( "album": AlbumSerializer(
instance.album, context={"include_ap_context": False} instance.album, context={"include_ap_context": False}
).data, ).data,
"attributedTo": instance.attributed_to.fid "attributedTo": (
if instance.attributed_to instance.attributed_to.fid if instance.attributed_to else None
else None, ),
"tag": self.get_tags_repr(instance), "tag": self.get_tags_repr(instance),
} }
include_content(d, instance.description) include_content(data, instance.description)
include_image(d, instance.attachment_cover) include_image(data, instance.attachment_cover)
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
d["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
return d return data
@transaction.atomic
def create(self, validated_data): def create(self, validated_data):
from funkwhale_api.music import tasks as music_tasks from funkwhale_api.music import tasks as music_tasks
...@@ -1486,18 +1555,21 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1486,18 +1555,21 @@ class TrackSerializer(MusicEntitySerializer):
validated_data, "album.attributedTo", permissive=True validated_data, "album.attributedTo", permissive=True
) )
) )
artists = ( artist_credit = (
common_utils.recursive_getattr(validated_data, "artists", permissive=True) common_utils.recursive_getattr(
validated_data, "artist_credit", permissive=True
)
or [] or []
) )
album_artists = ( album_artists_credit = (
common_utils.recursive_getattr( common_utils.recursive_getattr(
validated_data, "album.artists", permissive=True validated_data, "album.artist_credit", permissive=True
) )
or [] 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: for url in actors_to_fetch:
if not url: if not url:
...@@ -1510,8 +1582,9 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1510,8 +1582,9 @@ class TrackSerializer(MusicEntitySerializer):
from_activity = self.context.get("activity") from_activity = self.context.get("activity")
if from_activity: if from_activity:
metadata["from_activity_id"] = from_activity.pk 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 return track
def update(self, obj, validated_data): def update(self, obj, validated_data):
...@@ -1520,6 +1593,50 @@ class TrackSerializer(MusicEntitySerializer): ...@@ -1520,6 +1593,50 @@ class TrackSerializer(MusicEntitySerializer):
return super().update(obj, validated_data) 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): class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio]) type = serializers.ChoiceField(choices=[contexts.AS.Audio])
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
...@@ -1529,7 +1646,7 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -1529,7 +1646,7 @@ class UploadSerializer(jsonld.JsonLdSerializer):
updated = serializers.DateTimeField(required=False, allow_null=True) updated = serializers.DateTimeField(required=False, allow_null=True)
bitrate = serializers.IntegerField(min_value=0) bitrate = serializers.IntegerField(min_value=0)
size = 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) track = TrackSerializer(required=True)
...@@ -1565,8 +1682,9 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -1565,8 +1682,9 @@ class UploadSerializer(jsonld.JsonLdSerializer):
def validate_library(self, v): def validate_library(self, v):
lb = self.context.get("library") lb = self.context.get("library")
if lb: if lb:
if lb.fid != v: # the upload can come from a playlist lib
raise serializers.ValidationError("Invalid library") if lb.fid != v and not lb.playlist.library and lb.playlist.library.fid != v:
raise serializers.ValidationError("Invalid library fid")
return lb return lb
actor = self.context.get("actor") actor = self.context.get("actor")
...@@ -1578,10 +1696,10 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -1578,10 +1696,10 @@ class UploadSerializer(jsonld.JsonLdSerializer):
queryset=music_models.Library, queryset=music_models.Library,
serializer_class=LibrarySerializer, serializer_class=LibrarySerializer,
) )
except Exception: except Exception as e:
raise serializers.ValidationError("Invalid library") raise serializers.ValidationError(f"Invalid library : {e}")
if actor and library.actor != actor: if actor and library.actor != actor:
raise serializers.ValidationError("Invalid library") raise serializers.ValidationError("Invalid library, actor check fails")
return library return library
def update(self, instance, validated_data): def update(self, instance, validated_data):
...@@ -1632,16 +1750,17 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -1632,16 +1750,17 @@ class UploadSerializer(jsonld.JsonLdSerializer):
return music_models.Upload.objects.create(**data) return music_models.Upload.objects.create(**data)
def to_representation(self, instance): def to_representation(self, instance):
lib = instance.library if instance.library else self.context.get("library")
track = instance.track track = instance.track
d = { d = {
"type": "Audio", "type": "Audio",
"id": instance.get_federation_id(), "id": instance.get_federation_id(),
"library": instance.library.fid, "library": lib.fid,
"name": track.full_name, "name": track.full_name,
"published": instance.creation_date.isoformat(), "published": instance.creation_date.isoformat(),
"bitrate": instance.bitrate, "bitrate": instance.bitrate,
"size": instance.size, "size": instance.size,
"duration": instance.duration, "duration": duration_int_to_xml(instance.duration),
"url": [ "url": [
{ {
"href": utils.full_url(instance.listen_url_no_download), "href": utils.full_url(instance.listen_url_no_download),
...@@ -1655,10 +1774,8 @@ class UploadSerializer(jsonld.JsonLdSerializer): ...@@ -1655,10 +1774,8 @@ class UploadSerializer(jsonld.JsonLdSerializer):
}, },
], ],
"track": TrackSerializer(track, context={"include_ap_context": False}).data, "track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": contexts.AS.Public "to": (contexts.AS.Public if lib.privacy_level == "everyone" else ""),
if instance.library.privacy_level == "everyone" "attributedTo": lib.actor.fid,
else "",
"attributedTo": instance.library.actor.fid,
} }
if instance.modification_date: if instance.modification_date:
d["updated"] = instance.modification_date.isoformat() d["updated"] = instance.modification_date.isoformat()
...@@ -1697,9 +1814,7 @@ class FlagSerializer(jsonld.JsonLdSerializer): ...@@ -1697,9 +1814,7 @@ class FlagSerializer(jsonld.JsonLdSerializer):
try: try:
return utils.get_object_by_fid(v, local=True) return utils.get_object_by_fid(v, local=True)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(f"Unknown id {v} for reported object")
"Unknown id {} for reported object".format(v)
)
def validate_type(self, tags): def validate_type(self, tags):
if tags: if tags:
...@@ -1734,7 +1849,8 @@ class FlagSerializer(jsonld.JsonLdSerializer): ...@@ -1734,7 +1849,8 @@ class FlagSerializer(jsonld.JsonLdSerializer):
} }
report, created = moderation_models.Report.objects.update_or_create( 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) moderation_signals.report_created.send(sender=None, report=report)
return report return report
...@@ -1777,7 +1893,7 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer): ...@@ -1777,7 +1893,7 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer):
"actor": channel.actor, "actor": channel.actor,
"items": channel.library.uploads.for_federation() "items": channel.library.uploads.for_federation()
.order_by("-creation_date") .order_by("-creation_date")
.filter(track__artist=channel.artist), .filter(track__artist_credit__artist=channel.artist),
"type": "OrderedCollection", "type": "OrderedCollection",
} }
r = super().to_representation(conf) r = super().to_representation(conf)
...@@ -1788,16 +1904,15 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): ...@@ -1788,16 +1904,15 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
id = serializers.URLField(max_length=500) id = serializers.URLField(max_length=500)
type = serializers.ChoiceField(choices=[contexts.AS.Audio]) type = serializers.ChoiceField(choices=[contexts.AS.Audio])
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1) 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) 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) position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False) disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
album = serializers.URLField(max_length=500, required=False) album = serializers.URLField(max_length=500, required=False)
license = serializers.URLField(allow_null=True, required=False) license = serializers.URLField(allow_null=True, required=False)
attributedTo = serializers.URLField(max_length=500, required=False) attributedTo = serializers.URLField(max_length=500, required=False)
copyright = TruncatedCharField( copyright = serializers.CharField(
truncate_length=music_models.MAX_LENGTHS["COPYRIGHT"],
allow_null=True, allow_null=True,
required=False, required=False,
) )
...@@ -1848,7 +1963,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): ...@@ -1848,7 +1963,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
actor=actors.get_service_actor(), actor=actors.get_service_actor(),
serializer_class=AlbumSerializer, serializer_class=AlbumSerializer,
queryset=music_models.Album.objects.filter( 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): ...@@ -1879,9 +1994,9 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
"name": upload.track.title, "name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid, "attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(), "published": upload.creation_date.isoformat(),
"to": contexts.AS.Public "to": (
if upload.library.privacy_level == "everyone" contexts.AS.Public if upload.library.privacy_level == "everyone" else ""
else "", ),
"url": [ "url": [
{ {
"type": "Link", "type": "Link",
...@@ -1900,7 +2015,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): ...@@ -1900,7 +2015,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
if upload.track.local_license: if upload.track.local_license:
data["license"] = upload.track.local_license["identifiers"][0] 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.position, "position")
include_if_not_none(data, upload.track.disc_number, "disc") include_if_not_none(data, upload.track.disc_number, "disc")
include_if_not_none(data, upload.track.copyright, "copyright") include_if_not_none(data, upload.track.copyright, "copyright")
...@@ -1911,7 +2026,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): ...@@ -1911,7 +2026,7 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
tags = [item.tag.name for item in upload.get_all_tagged_items()] tags = [item.tag.name for item in upload.get_all_tagged_items()]
if tags: if tags:
data["tag"] = [repr_tag(name) for name in sorted(set(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): if self.context.get("include_ap_context", True):
data["@context"] = jsonld.get_default_context() data["@context"] = jsonld.get_default_context()
...@@ -1927,7 +2042,6 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): ...@@ -1927,7 +2042,6 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
now = timezone.now() now = timezone.now()
track_defaults = { track_defaults = {
"fid": validated_data["id"], "fid": validated_data["id"],
"artist": channel.artist,
"position": validated_data.get("position", 1), "position": validated_data.get("position", 1),
"disc_number": validated_data.get("disc", 1), "disc_number": validated_data.get("disc", 1),
"title": validated_data["name"], "title": validated_data["name"],
...@@ -1940,17 +2054,42 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer): ...@@ -1940,17 +2054,42 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
track_defaults["license"] = licenses.match(validated_data["license"]) track_defaults["license"] = licenses.match(validated_data["license"])
track, created = music_models.Track.objects.update_or_create( 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: if "image" in validated_data:
new_value = self.validated_data["image"] new_value = self.validated_data["image"]
common_utils.attach_file( common_utils.attach_file(
track, track,
"attachment_cover", "attachment_cover",
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")} {"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value if new_value
else None, else None
),
) )
common_utils.attach_content( common_utils.attach_content(
...@@ -2032,7 +2171,7 @@ class DeleteSerializer(jsonld.JsonLdSerializer): ...@@ -2032,7 +2171,7 @@ class DeleteSerializer(jsonld.JsonLdSerializer):
try: try:
obj = utils.get_object_by_fid(url) obj = utils.get_object_by_fid(url)
except utils.ObjectDoesNotExist: 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): if isinstance(obj, music_models.Upload):
obj = obj.track obj = obj.track
...@@ -2074,3 +2213,276 @@ class IndexSerializer(jsonld.JsonLdSerializer): ...@@ -2074,3 +2213,276 @@ class IndexSerializer(jsonld.JsonLdSerializer):
if self.context.get("include_ap_context", True): if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context() d["@context"] = jsonld.get_default_context()
return d 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)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
}
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,
)
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)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, listening):
payload = {
"type": "Listen",
"id": listening.fid,
"actor": listening.actor.fid,
"object": listening.track.fid,
}
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,
)
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 datetime
import logging import logging
import pytz import sys
import cryptography.exceptions
import requests
import requests_http_message_signatures
from django import forms from django import forms
from django.utils import timezone from django.utils import timezone
from django.utils.http import parse_http_date from django.utils.http import parse_http_date
import requests
import requests_http_signature
from . import exceptions, utils from . import exceptions, utils
if sys.version_info < (3, 9):
from backports.zoneinfo import ZoneInfo
else:
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# the request Date should be between now - 30s and now + 30s # the request Date should be between now - 30s and now + 30s
...@@ -26,13 +30,16 @@ def verify_date(raw_date): ...@@ -26,13 +30,16 @@ def verify_date(raw_date):
ts = parse_http_date(raw_date) ts = parse_http_date(raw_date)
except ValueError as e: except ValueError as e:
raise forms.ValidationError(str(e)) raise forms.ValidationError(str(e))
dt = datetime.datetime.utcfromtimestamp(ts) dt = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc)
dt = dt.replace(tzinfo=pytz.utc) dt = dt.replace(tzinfo=ZoneInfo("UTC"))
delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR) delta = datetime.timedelta(seconds=DATE_HEADER_VALID_FOR)
now = timezone.now() now = timezone.now()
if dt < now - delta or dt > now + delta: 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( 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 return dt
...@@ -45,8 +52,8 @@ def verify(request, public_key): ...@@ -45,8 +52,8 @@ def verify(request, public_key):
) )
verify_date(date) verify_date(date)
try: try:
return requests_http_signature.HTTPSignatureAuth.verify( return requests_http_message_signatures.HTTPSignatureHeaderAuth.verify(
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False request, key_resolver=lambda **kwargs: public_key
) )
except cryptography.exceptions.InvalidSignature: except cryptography.exceptions.InvalidSignature:
logger.warning( logger.warning(
...@@ -65,16 +72,16 @@ def verify_django(django_request, public_key): ...@@ -65,16 +72,16 @@ def verify_django(django_request, public_key):
headers = utils.clean_wsgi_headers(django_request.META) headers = utils.clean_wsgi_headers(django_request.META)
for h, v in list(headers.items()): for h, v in list(headers.items()):
# we include lower-cased version of the headers for compatibility # we include lower-cased version of the headers for compatibility
# with requests_http_signature # with requests_http_message_signatures
headers[h.lower()] = v headers[h.lower()] = v
try: try:
signature = headers["Signature"] signature = headers["Signature"]
except KeyError: except KeyError:
raise exceptions.MissingSignature raise exceptions.MissingSignature
url = "http://noop{}".format(django_request.path) url = f"http://noop{django_request.path}"
query = django_request.META["QUERY_STRING"] query = django_request.META["QUERY_STRING"]
if query: if query:
url += "?{}".format(query) url += f"?{query}"
signature_headers = signature.split('headers="')[1].split('",')[0] signature_headers = signature.split('headers="')[1].split('",')[0]
expected = signature_headers.split(" ") expected = signature_headers.split(" ")
logger.debug("Signature expected headers: %s", expected) logger.debug("Signature expected headers: %s", expected)
...@@ -98,8 +105,7 @@ def verify_django(django_request, public_key): ...@@ -98,8 +105,7 @@ def verify_django(django_request, public_key):
def get_auth(private_key, private_key_id): def get_auth(private_key, private_key_id):
return requests_http_signature.HTTPSignatureAuth( return requests_http_message_signatures.HTTPSignatureHeaderAuth(
use_auth_header=False,
headers=["(request-target)", "user-agent", "host", "date"], headers=["(request-target)", "user-agent", "host", "date"],
algorithm="rsa-sha256", algorithm="rsa-sha256",
key=private_key.encode("utf-8"), key=private_key.encode("utf-8"),
......
from django.conf import settings from django.conf import settings
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import preferences from funkwhale_api.common import middleware, preferences, utils
from funkwhale_api.common import middleware
from funkwhale_api.common import utils
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from . import models from . import models
......
...@@ -2,35 +2,39 @@ import datetime ...@@ -2,35 +2,39 @@ import datetime
import json import json
import logging import logging
import os import os
import requests
import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.db import transaction 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.db.models.deletion import Collector
from django.utils import timezone from django.utils import timezone
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from requests.exceptions import RequestException from requests.exceptions import RequestException
from funkwhale_api.audio import models as audio_models 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 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.common import utils as common_utils
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from funkwhale_api.music import models as music_models 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 funkwhale_api.taskapp import celery
from . import activity from . import (
from . import actors activity,
from . import exceptions actors,
from . import jsonld exceptions,
from . import keys jsonld,
from . import models, signing keys,
from . import serializers models,
from . import routes routes,
from . import utils serializers,
from . import webfinger signing,
utils,
webfinger,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -137,7 +141,6 @@ def dispatch_outbox(activity): ...@@ -137,7 +141,6 @@ def dispatch_outbox(activity):
"delivery", "delivery",
) )
def deliver_to_remote(delivery): def deliver_to_remote(delivery):
if not preferences.get("federation__enabled"): if not preferences.get("federation__enabled"):
# federation is disabled, we only deliver to local recipients # federation is disabled, we only deliver to local recipients
return return
...@@ -168,7 +171,7 @@ def deliver_to_remote(delivery): ...@@ -168,7 +171,7 @@ def deliver_to_remote(delivery):
def fetch_nodeinfo(domain_name): def fetch_nodeinfo(domain_name):
s = session.get_session() 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 = s.get(url=wellknown_url)
response.raise_for_status() response.raise_for_status()
serializer = serializers.NodeInfoSerializer(data=response.json()) serializer = serializers.NodeInfoSerializer(data=response.json())
...@@ -206,7 +209,7 @@ def update_domain_nodeinfo(domain): ...@@ -206,7 +209,7 @@ def update_domain_nodeinfo(domain):
domain.service_actor = ( domain.service_actor = (
utils.retrieve_ap_object( utils.retrieve_ap_object(
service_actor_id, service_actor_id,
actor=actors.get_service_actor(), actor=None,
queryset=models.Actor, queryset=models.Actor,
serializer_class=serializers.ActorSerializer, serializer_class=serializers.ActorSerializer,
) )
...@@ -233,8 +236,10 @@ def refresh_nodeinfo_known_nodes(): ...@@ -233,8 +236,10 @@ def refresh_nodeinfo_known_nodes():
settings.NODEINFO_REFRESH_DELAY settings.NODEINFO_REFRESH_DELAY
""" """
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY) limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
candidates = models.Domain.objects.external().exclude( candidates = (
nodeinfo_fetch_date__gte=limit models.Domain.objects.external()
.exclude(nodeinfo_fetch_date__gte=limit)
.filter(nodeinfo__software__name="Funkwhale")
) )
names = candidates.values_list("name", flat=True) names = candidates.values_list("name", flat=True)
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names)) logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))
...@@ -326,7 +331,7 @@ def fetch(fetch_obj): ...@@ -326,7 +331,7 @@ def fetch(fetch_obj):
auth = None auth = None
try: try:
if url.startswith("webfinger://"): if url.startswith("webfinger://"):
# we first grab the correpsonding webfinger representation # we first grab the corresponding webfinger representation
# to get the ActivityPub actor ID # to get the ActivityPub actor ID
webfinger_data = webfinger.get_resource( webfinger_data = webfinger.get_resource(
"acct:" + url.replace("webfinger://", "") "acct:" + url.replace("webfinger://", "")
...@@ -338,7 +343,9 @@ def fetch(fetch_obj): ...@@ -338,7 +343,9 @@ def fetch(fetch_obj):
if not payload: if not payload:
return error("blocked", message="Blocked by MRF") return error("blocked", message="Blocked by MRF")
response = session.get_session().get( 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) logger.debug("Remote answered with %s: %s", response.status_code, response.text)
response.raise_for_status() response.raise_for_status()
...@@ -346,7 +353,7 @@ def fetch(fetch_obj): ...@@ -346,7 +353,7 @@ def fetch(fetch_obj):
return error( return error(
"http", "http",
status_code=e.response.status_code if e.response else None, status_code=e.response.status_code if e.response else None,
message=response.text, message=e.response.text,
) )
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
return error("timeout") return error("timeout")
...@@ -425,7 +432,9 @@ def fetch(fetch_obj): ...@@ -425,7 +432,9 @@ def fetch(fetch_obj):
# first page fetch is synchronous, so that at least some data is available # first page fetch is synchronous, so that at least some data is available
# in the UI after subscription # in the UI after subscription
result = fetch_collection( 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: except Exception:
logger.exception( logger.exception(
...@@ -461,7 +470,9 @@ class PreserveSomeDataCollector(Collector): ...@@ -461,7 +470,9 @@ class PreserveSomeDataCollector(Collector):
def related_objects(self, related, *args, **kwargs): def related_objects(self, related, *args, **kwargs):
qs = super().related_objects(related, *args, **kwargs) qs = super().related_objects(related, *args, **kwargs)
if related.name == "outbox_activities": # We can only exclude the actions if these fields are available, most likely its a
# model.Activity than
if hasattr(related, "type") and hasattr(related, "creation_date"):
# exclude the delete activity can be broadcasted properly # exclude the delete activity can be broadcasted properly
qs = qs.exclude(type="Delete", creation_date__gte=self.creation_date) qs = qs.exclude(type="Delete", creation_date__gte=self.creation_date)
...@@ -471,7 +482,8 @@ class PreserveSomeDataCollector(Collector): ...@@ -471,7 +482,8 @@ class PreserveSomeDataCollector(Collector):
@celery.app.task(name="federation.remove_actor") @celery.app.task(name="federation.remove_actor")
@transaction.atomic @transaction.atomic
@celery.require_instance( @celery.require_instance(
models.Actor.objects.all(), "actor", models.Actor.objects.all(),
"actor",
) )
def remove_actor(actor): def remove_actor(actor):
# Then we broadcast the info over federation. We do this *before* deleting objects # Then we broadcast the info over federation. We do this *before* deleting objects
...@@ -529,7 +541,9 @@ def match_serializer(payload, conf): ...@@ -529,7 +541,9 @@ def match_serializer(payload, conf):
@celery.app.task(name="federation.fetch_collection") @celery.app.task(name="federation.fetch_collection")
@celery.require_instance( @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): def fetch_collection(url, max_pages, channel, is_page=False):
actor = actors.get_service_actor() actor = actors.get_service_actor()
...@@ -541,8 +555,8 @@ def fetch_collection(url, max_pages, channel, is_page=False): ...@@ -541,8 +555,8 @@ def fetch_collection(url, max_pages, channel, is_page=False):
"total": 0, "total": 0,
} }
if is_page: if is_page:
# starting immediatly from a page, no need to fetch the wrapping collection # starting immediately from a page, no need to fetch the wrapping collection
logger.debug("Fetch collection page immediatly at %s", url) logger.debug("Fetch collection page immediately at %s", url)
results["next_page"] = url results["next_page"] = url
else: else:
logger.debug("Fetching collection object at %s", url) logger.debug("Fetching collection object at %s", url)
...@@ -562,7 +576,11 @@ def fetch_collection(url, max_pages, channel, is_page=False): ...@@ -562,7 +576,11 @@ def fetch_collection(url, max_pages, channel, is_page=False):
for i in range(max_pages): for i in range(max_pages):
page_url = results["next_page"] page_url = results["next_page"]
logger.debug("Handling page %s on max %s, at %s", i + 1, max_pages, page_url) 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: try:
items = page["orderedItems"] items = page["orderedItems"]
except KeyError: except KeyError:
...@@ -612,3 +630,53 @@ def fetch_collection(url, max_pages, channel, is_page=False): ...@@ -612,3 +630,53 @@ def fetch_collection(url, max_pages, channel, is_page=False):
results["errored"], results["errored"],
) )
return results return results
@celery.app.task(name="federation.check_all_remote_instance_availability")
def check_all_remote_instance_availability():
domains = models.Domain.objects.all().prefetch_related()
for domain in domains:
if domain.name == settings.FUNKWHALE_HOSTNAME:
# No need to check the instance itself: Its always reachable
domain.reachable = True
domain.last_successful_contact = timezone.now()
else:
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.save()
return domain.reachable
if "version" in nodeinfo.keys():
domain.reachable = True
domain.last_successful_contact = timezone.now()
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.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 rest_framework import routers
from . import views from . import views
...@@ -16,13 +17,20 @@ router.register(r".well-known", views.WellKnownViewSet, "well-known") ...@@ -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"libraries", views.MusicLibraryViewSet, "libraries")
music_router.register(r"uploads", views.MusicUploadViewSet, "uploads") music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
music_router.register(r"artists", views.MusicArtistViewSet, "artists") 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"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks") 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") index_router.register(r"index", views.IndexViewSet, "index")
urlpatterns = router.urls + [ urlpatterns = router.urls + [
url("federation/music/", include((music_router.urls, "music"), namespace="music")), re_path(
url("federation/", include((index_router.urls, "index"), namespace="index")), "federation/music/", include((music_router.urls, "music"), namespace="music")
),
re_path("federation/", include((index_router.urls, "index"), namespace="index")),
] ]
import html.parser import html.parser
import re
import unicodedata import unicodedata
import urllib.parse import urllib.parse
import re
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
...@@ -11,8 +11,7 @@ from django.db.models import CharField, Q, Value ...@@ -11,8 +11,7 @@ from django.db.models import CharField, Q, Value
from funkwhale_api.common import session from funkwhale_api.common import session
from funkwhale_api.moderation import mrf from funkwhale_api.moderation import mrf
from . import exceptions from . import exceptions, signing
from . import signing
def full_url(path): def full_url(path):
...@@ -67,7 +66,11 @@ def slugify_username(username): ...@@ -67,7 +66,11 @@ def slugify_username(username):
def retrieve_ap_object( 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 # we have a duplicate check here because it's less expensive to do those checks
# twice than to trigger a HTTP request # twice than to trigger a HTTP request
...@@ -119,10 +122,8 @@ def get_domain_query_from_url(domain, url_field="fid"): ...@@ -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. to match objects that have this domain in the given field.
""" """
query = Q(**{"{}__startswith".format(url_field): "http://{}/".format(domain)}) query = Q(**{f"{url_field}__startswith": f"http://{domain}/"})
query = query | Q( query = query | Q(**{f"{url_field}__startswith": f"https://{domain}/"})
**{"{}__startswith".format(url_field): "https://{}/".format(domain)}
)
return query return query
...@@ -135,18 +136,15 @@ def local_qs(queryset, url_field="fid", include=True): ...@@ -135,18 +136,15 @@ def local_qs(queryset, url_field="fid", include=True):
return queryset.filter(query) return queryset.filter(query)
def is_local(url): def is_local(url) -> bool:
if not url: if not url:
return True return True
d = settings.FEDERATION_HOSTNAME d = settings.FEDERATION_HOSTNAME
return url.startswith("http://{}/".format(d)) or url.startswith( return url.startswith(f"http://{d}/") or url.startswith(f"https://{d}/")
"https://{}/".format(d)
)
def get_actor_data_from_username(username): def get_actor_data_from_username(username):
parts = username.split("@") parts = username.split("@")
return { return {
...@@ -161,8 +159,8 @@ def get_actor_from_username_data_query(field, data): ...@@ -161,8 +159,8 @@ def get_actor_from_username_data_query(field, data):
if field: if field:
return Q( return Q(
**{ **{
"{}__preferred_username__iexact".format(field): data["username"], f"{field}__preferred_username__iexact": data["username"],
"{}__domain__name__iexact".format(field): data["domain"], f"{field}__domain__name__iexact": data["domain"],
} }
) )
else: else:
...@@ -244,8 +242,7 @@ FID_MODEL_LABELS = [ ...@@ -244,8 +242,7 @@ FID_MODEL_LABELS = [
def get_object_by_fid(fid, local=None): def get_object_by_fid(fid, local=None):
if local:
if local is True:
parsed = urllib.parse.urlparse(fid) parsed = urllib.parse.urlparse(fid)
if parsed.netloc != settings.FEDERATION_HOSTNAME: if parsed.netloc != settings.FEDERATION_HOSTNAME:
raise ObjectDoesNotExist() raise ObjectDoesNotExist()
......
...@@ -7,16 +7,20 @@ from django.urls import reverse ...@@ -7,16 +7,20 @@ from django.urls import reverse
from rest_framework import exceptions, mixins, permissions, response, viewsets from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action 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 preferences
from funkwhale_api.common import utils as common_utils 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.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.moderation import models as moderation_models
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
from funkwhale_api.playlists import models as playlists_models
from . import ( from . import (
actors,
activity, activity,
actors,
authentication, authentication,
models, models,
renderers, renderers,
...@@ -68,7 +72,7 @@ class AuthenticatedIfAllowListEnabled(permissions.BasePermission): ...@@ -68,7 +72,7 @@ class AuthenticatedIfAllowListEnabled(permissions.BasePermission):
return bool(request.actor) return bool(request.actor)
class FederationMixin(object): class FederationMixin:
permission_classes = [AuthenticatedIfAllowListEnabled] permission_classes = [AuthenticatedIfAllowListEnabled]
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
...@@ -161,7 +165,9 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV ...@@ -161,7 +165,9 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
"actor": channel.actor, "actor": channel.actor,
"items": channel.library.uploads.for_federation() "items": channel.library.uploads.for_federation()
.order_by("-creation_date") .order_by("-creation_date")
.prefetch_related("library__channel__actor", "track__artist"), .prefetch_related(
"library__channel__actor", "track__artist_credit__artist"
),
"item_serializer": serializers.ChannelCreateUploadSerializer, "item_serializer": serializers.ChannelCreateUploadSerializer,
} }
return get_collection_response( return get_collection_response(
...@@ -170,17 +176,115 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV ...@@ -170,17 +176,115 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
collection_serializer=serializers.ChannelOutboxSerializer(channel), 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): def followers(self, request, *args, **kwargs):
self.get_object() actor = self.get_object()
# XXX to implement followers = list(actor.get_approved_followers())
return response.Response({}) 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): def following(self, request, *args, **kwargs):
self.get_object() actor = self.get_object()
# XXX to implement followings = list(
return response.Response({}) 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): class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
...@@ -223,9 +327,9 @@ class WellKnownViewSet(viewsets.GenericViewSet): ...@@ -223,9 +327,9 @@ class WellKnownViewSet(viewsets.GenericViewSet):
return HttpResponse(status=405) return HttpResponse(status=405)
try: try:
resource_type, resource = webfinger.clean_resource(request.GET["resource"]) 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) result = cleaner(resource)
handler = getattr(self, "handler_{}".format(resource_type)) handler = getattr(self, f"handler_{resource_type}")
data = handler(result) data = handler(result)
except forms.ValidationError as e: except forms.ValidationError as e:
return response.Response({"errors": {"resource": e.message}}, status=400) return response.Response({"errors": {"resource": e.message}}, status=400)
...@@ -261,6 +365,20 @@ def has_library_access(request, library): ...@@ -261,6 +365,20 @@ def has_library_access(request, library):
return library.received_follows.filter(actor=actor, approved=True).exists() 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( class MusicLibraryViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
...@@ -279,39 +397,41 @@ class MusicLibraryViewSet( ...@@ -279,39 +397,41 @@ class MusicLibraryViewSet(
lb = self.get_object() lb = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")): if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(lb.get_absolute_url()) 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 = { conf = {
"id": lb.get_federation_id(), "id": lb.get_federation_id(),
"actor": lb.actor, "actor": lb.actor,
"name": lb.name, "name": lb.name,
"summary": lb.description, "items": items_qs.order_by("-creation_date").prefetch_related(
"items": lb.uploads.for_federation()
.order_by("-creation_date")
.prefetch_related(
Prefetch( Prefetch(
"track", "track",
queryset=music_models.Track.objects.select_related( queryset=music_models.Track.objects.select_related(
"album__artist__attributed_to",
"artist__attributed_to",
"artist__attachment_cover",
"attachment_cover", "attachment_cover",
"album__attributed_to", "album__attributed_to",
"attributed_to", "attributed_to",
"album__attachment_cover", "album__attachment_cover",
"album__artist__attachment_cover",
"description", "description",
).prefetch_related( ).prefetch_related(
"album__artist_credit__artist__attributed_to",
"artist_credit__artist__attributed_to",
"artist_credit__artist__attachment_cover",
"tagged_items__tag", "tagged_items__tag",
"album__tagged_items__tag", "album__tagged_items__tag",
"album__artist__tagged_items__tag", "album__artist_credit__artist__tagged_items__tag",
"artist__tagged_items__tag", "album__artist_credit__artist__attachment_cover",
"artist__description", "artist_credit__artist__tagged_items__tag",
"artist_credit__artist__description",
"album__description", "album__description",
), ),
) )
), ),
"item_serializer": serializers.UploadSerializer, "item_serializer": serializers.UploadSerializer,
"library": lb,
} }
return get_collection_response( return get_collection_response(
conf=conf, conf=conf,
querystring=request.GET, querystring=request.GET,
...@@ -331,16 +451,21 @@ class MusicUploadViewSet( ...@@ -331,16 +451,21 @@ class MusicUploadViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Upload.objects.local().select_related( queryset = (
music_models.Upload.objects.local()
.select_related(
"library__actor", "library__actor",
"track__artist",
"track__album__artist",
"track__description", "track__description",
"track__album__attachment_cover", "track__album__attachment_cover",
"track__album__artist__attachment_cover",
"track__artist__attachment_cover",
"track__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 serializer_class = serializers.UploadSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -393,13 +518,35 @@ class MusicArtistViewSet( ...@@ -393,13 +518,35 @@ class MusicArtistViewSet(
return response.Response(serializer.data) 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( class MusicAlbumViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Album.objects.local().select_related( queryset = (
"artist__description", "description", "artist__attachment_cover" music_models.Album.objects.local()
.prefetch_related(
"artist_credit__artist__description",
"artist_credit__artist__attachment_cover",
)
.select_related(
"description",
)
) )
serializer_class = serializers.AlbumSerializer serializer_class = serializers.AlbumSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -418,16 +565,22 @@ class MusicTrackViewSet( ...@@ -418,16 +565,22 @@ class MusicTrackViewSet(
): ):
authentication_classes = [authentication.SignatureAuthentication] authentication_classes = [authentication.SignatureAuthentication]
renderer_classes = renderers.get_ap_renderers() renderer_classes = renderers.get_ap_renderers()
queryset = music_models.Track.objects.local().select_related( queryset = (
"album__artist", music_models.Track.objects.local()
.select_related(
"album__description", "album__description",
"artist__description",
"description", "description",
"attachment_cover", "attachment_cover",
"album__artist__attachment_cover",
"album__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 serializer_class = serializers.TrackSerializer
lookup_field = "uuid" lookup_field = "uuid"
...@@ -470,7 +623,8 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet): ...@@ -470,7 +623,8 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@action( @action(
methods=["get"], detail=False, methods=["get"],
detail=False,
) )
def libraries(self, request, *args, **kwargs): def libraries(self, request, *args, **kwargs):
libraries = ( libraries = (
...@@ -497,7 +651,8 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet): ...@@ -497,7 +651,8 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response({}, status=200) return response.Response({}, status=200)
@action( @action(
methods=["get"], detail=False, methods=["get"],
detail=False,
) )
def channels(self, request, *args, **kwargs): def channels(self, request, *args, **kwargs):
actors = ( actors = (
...@@ -525,3 +680,95 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet): ...@@ -525,3 +680,95 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
) )
return response.Response({}, status=200) 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): ...@@ -30,7 +30,7 @@ def clean_acct(acct_string, ensure_local=True):
raise forms.ValidationError("Invalid format") raise forms.ValidationError("Invalid format")
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME: 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 return username, hostname
......
...@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer) ...@@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer)
@record.registry.register_consumer("history.Listening") @record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj): 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 return
channels.group_send( channels.group_send(
......
...@@ -5,6 +5,6 @@ from . import models ...@@ -5,6 +5,6 @@ from . import models
@admin.register(models.Listening) @admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin): class ListeningAdmin(admin.ModelAdmin):
list_display = ["track", "creation_date", "user", "session_key"] list_display = ["track", "creation_date", "actor", "session_key"]
search_fields = ["track__name", "user__username"] search_fields = ["track__name", "actor__user__username"]
list_select_related = ["user", "track"] list_select_related = ["actor", "track"]
import factory 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.music import factories
from funkwhale_api.users.factories import UserFactory
@registry.register @registry.register
class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory) actor = factory.SubFactory(ActorFactory)
track = factory.SubFactory(factories.TrackFactory) track = factory.SubFactory(factories.TrackFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta: class Meta:
model = "history.Listening" 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 ...@@ -7,9 +7,9 @@ from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet): class ListeningFilter(moderation_filters.HiddenContentFilterSet):
username = django_filters.CharFilter("user__username") username = django_filters.CharFilter("actor__user__username")
domain = django_filters.CharFilter("user__actor__domain_id") domain = django_filters.CharFilter("actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True) scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta: class Meta:
model = models.Listening 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",
),
),
]
import uuid
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
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 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) creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey( track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE Track, related_name="listenings", on_delete=models.CASCADE
) )
user = models.ForeignKey( actor = models.ForeignKey(
"users.User", "federation.Actor",
related_name="listenings", related_name="listenings",
null=True,
blank=True,
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=False,
blank=False,
) )
session_key = models.CharField(max_length=100, null=True, blank=True) 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: class Meta:
ordering = ("-creation_date",) ordering = ("-creation_date",)
def get_activity_url(self): 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()
return super().save(**kwargs)
...@@ -3,7 +3,6 @@ from rest_framework import serializers ...@@ -3,7 +3,6 @@ from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models from . import models
...@@ -11,46 +10,39 @@ from . import models ...@@ -11,46 +10,39 @@ from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer): class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track") object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user") actor = federation_serializers.APIActorSerializer()
published = serializers.DateTimeField(source="creation_date") published = serializers.DateTimeField(source="creation_date")
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = ["id", "local_id", "object", "type", "actor", "published"] fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj): def get_type(self, obj):
return "Listen" return "Listen"
class ListeningSerializer(serializers.ModelSerializer): class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True) track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True) actor = federation_serializers.APIActorSerializer(read_only=True)
actor = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = ("id", "user", "track", "creation_date", "actor") fields = ("id", "actor", "track", "creation_date", "actor")
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["user"] validated_data["actor"] = self.context["user"].actor
return super().create(validated_data) 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): class ListeningWriteSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True, required=False)
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = ("id", "user", "track", "creation_date") fields = ("id", "actor", "track", "creation_date")
def create(self, validated_data): def create(self, validated_data):
validated_data["user"] = self.context["user"] validated_data["actor"] = self.context["user"].actor
return super().create(validated_data) return super().create(validated_data)
from rest_framework import mixins, viewsets
from django.db.models import Prefetch from django.db.models import Prefetch
from rest_framework import mixins, viewsets
from config import plugins from config import plugins
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions 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 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 funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
class ListeningViewSet( class ListeningViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
...@@ -19,11 +18,8 @@ class ListeningViewSet( ...@@ -19,11 +18,8 @@ class ListeningViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
serializer_class = serializers.ListeningSerializer serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related( queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
"user__actor__attachment_icon"
)
permission_classes = [ permission_classes = [
oauth_permissions.ScopePermission, oauth_permissions.ScopePermission,
...@@ -32,6 +28,7 @@ class ListeningViewSet( ...@@ -32,6 +28,7 @@ class ListeningViewSet(
required_scope = "listenings" required_scope = "listenings"
anonymous_policy = "setting" anonymous_policy = "setting"
owner_checks = ["write"] owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter filterset_class = filters.ListeningFilter
def get_serializer_class(self): def get_serializer_class(self):
...@@ -41,22 +38,41 @@ class ListeningViewSet( ...@@ -41,22 +38,41 @@ class ListeningViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
r = super().perform_create(serializer) r = super().perform_create(serializer)
instance = serializer.instance
plugins.trigger_hook( plugins.trigger_hook(
plugins.LISTENING_CREATED, plugins.LISTENING_CREATED,
listening=serializer.instance, listening=instance,
confs=plugins.get_confs(self.request.user), 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) record.send(serializer.instance)
return r return r
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
queryset = queryset.filter( queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level") fields.privacy_level_query(
self.request.user, "actor__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) music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist", "attributed_to") )
.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)) return queryset.prefetch_related(Prefetch("track", queryset=tracks))
def get_serializer_context(self): def get_serializer_context(self):
......
from django.forms import widgets import pycountry
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.forms import widgets
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
...@@ -73,7 +73,7 @@ class InstanceContactEmail(types.StringPreference): ...@@ -73,7 +73,7 @@ class InstanceContactEmail(types.StringPreference):
name = "contact_email" name = "contact_email"
verbose_name = "Contact email" verbose_name = "Contact email"
default = "" default = ""
help_text = "A contact email for visitors who need to contact an admin or moderator" help_text = "A contact e-mail address for visitors who need to contact an admin or moderator"
field_kwargs = {"required": False} field_kwargs = {"required": False}
...@@ -171,3 +171,18 @@ class Banner(ImagePreference): ...@@ -171,3 +171,18 @@ class Banner(ImagePreference):
default = None default = None
help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended." help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended."
field_kwargs = {"required": False} 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
{
"name": "Funkwhale",
"categories": ["music", "entertainment"],
"short_name": "Funkwhale",
"description": "Your free and federated audio platform",
"icons": [
{
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=audio.funkwhale.ffa",
"id": "audio.funkwhale.ffa"
},
{
"platform": "f-droid",
"url": "https://f-droid.org/en/packages/audio.funkwhale.ffa/",
"id": "audio.funkwhale.ffa"
}
],
"shortcuts": [
{
"name": "Search",
"url": "/search",
"icons": []
},
{
"name": "Library",
"url": "/library",
"icons": []
},
{
"name": "Channels",
"url": "/subscriptions",
"icons": []
}
]
}