Merge branch 'attribute-artist' into 'develop'
Attribute artist See merge request funkwhale/funkwhale!713
This commit is contained in:
commit
b45cada689
|
@ -2,7 +2,7 @@ import persisting_theory
|
|||
|
||||
from rest_framework import serializers
|
||||
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
|
||||
|
||||
class ConfNotFound(KeyError):
|
||||
|
@ -23,6 +23,7 @@ class Registry(persisting_theory.Registry):
|
|||
|
||||
return decorator
|
||||
|
||||
@transaction.atomic
|
||||
def apply(self, type, obj, payload):
|
||||
conf = self.get_conf(type, obj)
|
||||
serializer = conf["serializer_class"](obj, data=payload)
|
||||
|
@ -73,6 +74,9 @@ class MutationSerializer(serializers.Serializer):
|
|||
def apply(self, obj, validated_data):
|
||||
raise NotImplementedError()
|
||||
|
||||
def post_apply(self, obj, validated_data):
|
||||
pass
|
||||
|
||||
def get_previous_state(self, obj, validated_data):
|
||||
return
|
||||
|
||||
|
@ -88,8 +92,11 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
|
|||
kwargs.setdefault("partial", True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@transaction.atomic
|
||||
def apply(self, obj, validated_data):
|
||||
return self.update(obj, validated_data)
|
||||
r = self.update(obj, validated_data)
|
||||
self.post_apply(r, validated_data)
|
||||
return r
|
||||
|
||||
def validate(self, validated_data):
|
||||
if not validated_data:
|
||||
|
|
|
@ -201,3 +201,30 @@ def concat_dicts(*dicts):
|
|||
n.update(d)
|
||||
|
||||
return n
|
||||
|
||||
|
||||
def get_updated_fields(conf, data, obj):
|
||||
"""
|
||||
Given a list of fields, a dict and an object, will return the dict keys/values
|
||||
that differ from the corresponding fields on the object.
|
||||
"""
|
||||
final_conf = []
|
||||
for c in conf:
|
||||
if isinstance(c, str):
|
||||
final_conf.append((c, c))
|
||||
else:
|
||||
final_conf.append(c)
|
||||
|
||||
final_data = {}
|
||||
|
||||
for data_field, obj_field in final_conf:
|
||||
try:
|
||||
data_value = data[data_field]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
obj_value = getattr(obj, obj_field)
|
||||
if obj_value != data_value:
|
||||
final_data[obj_field] = data_value
|
||||
|
||||
return final_data
|
||||
|
|
|
@ -2,6 +2,8 @@ import uuid
|
|||
import factory
|
||||
import persisting_theory
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from faker.providers import internet as internet_provider
|
||||
|
||||
|
||||
|
@ -50,11 +52,11 @@ class FunkwhaleProvider(internet_provider.Provider):
|
|||
not random enough
|
||||
"""
|
||||
|
||||
def federation_url(self, prefix=""):
|
||||
def federation_url(self, prefix="", local=False):
|
||||
def path_generator():
|
||||
return "{}/{}".format(prefix, uuid.uuid4())
|
||||
|
||||
domain = self.domain_name()
|
||||
domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
|
||||
protocol = "https"
|
||||
path = path_generator()
|
||||
return "{}://{}/{}".format(protocol, domain, path)
|
||||
|
|
|
@ -365,27 +365,6 @@ class OutboxRouter(Router):
|
|||
return activities
|
||||
|
||||
|
||||
def recursive_getattr(obj, key, permissive=False):
|
||||
"""
|
||||
Given a dictionary such as {'user': {'name': 'Bob'}} and
|
||||
a dotted string such as user.name, returns 'Bob'.
|
||||
|
||||
If the value is not present, returns None
|
||||
"""
|
||||
v = obj
|
||||
for k in key.split("."):
|
||||
try:
|
||||
v = v.get(k)
|
||||
except (TypeError, AttributeError):
|
||||
if not permissive:
|
||||
raise
|
||||
return
|
||||
if v is None:
|
||||
return
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def match_route(route, payload):
|
||||
for key, value in route.items():
|
||||
payload_value = recursive_getattr(payload, key, permissive=True)
|
||||
|
@ -432,6 +411,27 @@ def prepare_deliveries_and_inbox_items(recipient_list, type):
|
|||
remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
|
||||
urls.append(r["target"].followers_url)
|
||||
|
||||
elif isinstance(r, dict) and r["type"] == "instances_with_followers":
|
||||
# we want to broadcast the activity to other instances service actors
|
||||
# when we have at least one follower from this instance
|
||||
follows = (
|
||||
models.LibraryFollow.objects.filter(approved=True)
|
||||
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
|
||||
.exclude(actor__domain=None)
|
||||
.union(
|
||||
models.Follow.objects.filter(approved=True)
|
||||
.exclude(actor__domain_id=settings.FEDERATION_HOSTNAME)
|
||||
.exclude(actor__domain=None)
|
||||
)
|
||||
)
|
||||
actors = models.Actor.objects.filter(
|
||||
managed_domains__name__in=follows.values_list(
|
||||
"actor__domain_id", flat=True
|
||||
)
|
||||
)
|
||||
values = actors.values("shared_inbox_url", "inbox_url")
|
||||
for v in values:
|
||||
remote_inbox_urls.add(v["shared_inbox_url"] or v["inbox_url"])
|
||||
deliveries = [models.Delivery(inbox_url=url) for url in remote_inbox_urls]
|
||||
inbox_items = [
|
||||
models.InboxItem(actor=actor, type=type) for actor in local_recipients
|
||||
|
|
|
@ -75,6 +75,15 @@ class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
model = "federation.Domain"
|
||||
django_get_or_create = ("name",)
|
||||
|
||||
@factory.post_generation
|
||||
def with_service_actor(self, create, extracted, **kwargs):
|
||||
if not create or not extracted:
|
||||
return
|
||||
|
||||
self.service_actor = ActorFactory(domain=self)
|
||||
self.save(update_fields=["service_actor"])
|
||||
return self.service_actor
|
||||
|
||||
|
||||
@registry.register
|
||||
class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
|
||||
|
|
|
@ -57,7 +57,9 @@ def insert_context(ctx, doc):
|
|||
existing = doc["@context"]
|
||||
if isinstance(existing, list):
|
||||
if ctx not in existing:
|
||||
existing = existing[:]
|
||||
existing.append(ctx)
|
||||
doc["@context"] = existing
|
||||
else:
|
||||
doc["@context"] = [existing, ctx]
|
||||
return doc
|
||||
|
@ -215,6 +217,15 @@ def get_default_context():
|
|||
return ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}]
|
||||
|
||||
|
||||
def get_default_context_fw():
|
||||
return [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{},
|
||||
"https://funkwhale.audio/ns",
|
||||
]
|
||||
|
||||
|
||||
class JsonLdSerializer(serializers.Serializer):
|
||||
def run_validation(self, data=empty):
|
||||
if data and data is not empty and self.context.get("expand", True):
|
||||
|
|
|
@ -264,6 +264,25 @@ class Actor(models.Model):
|
|||
self.private_key = v[0].decode("utf-8")
|
||||
self.public_key = v[1].decode("utf-8")
|
||||
|
||||
def can_manage(self, obj):
|
||||
attributed_to = getattr(obj, "attributed_to_id", None)
|
||||
if attributed_to is not None and attributed_to == self.pk:
|
||||
# easiest case, the obj is attributed to the actor
|
||||
return True
|
||||
|
||||
if self.domain.service_actor_id != self.pk:
|
||||
# actor is not system actor, so there is no way the actor can manage
|
||||
# the object
|
||||
return False
|
||||
|
||||
# actor is service actor of its domain, so if the fid domain
|
||||
# matches, we consider the actor has the permission to manage
|
||||
# the object
|
||||
domain = self.domain_id
|
||||
return obj.fid.startswith("http://{}/".format(domain)) or obj.fid.startswith(
|
||||
"https://{}/".format(domain)
|
||||
)
|
||||
|
||||
|
||||
class InboxItem(models.Model):
|
||||
"""
|
||||
|
|
|
@ -3,6 +3,7 @@ import logging
|
|||
from funkwhale_api.music import models as music_models
|
||||
|
||||
from . import activity
|
||||
from . import actors
|
||||
from . import serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -269,3 +270,79 @@ def outbox_delete_audio(context):
|
|||
serializer.data, to=[{"type": "followers", "target": library}]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def handle_library_entry_update(payload, context, queryset, serializer_class):
|
||||
actor = context["actor"]
|
||||
obj_id = payload["object"].get("id")
|
||||
if not obj_id:
|
||||
logger.debug("Discarding update of empty obj")
|
||||
return
|
||||
|
||||
try:
|
||||
obj = queryset.select_related("attributed_to").get(fid=obj_id)
|
||||
except queryset.model.DoesNotExist:
|
||||
logger.debug("Discarding update of unkwnown obj %s", obj_id)
|
||||
return
|
||||
if not actor.can_manage(obj):
|
||||
logger.debug(
|
||||
"Discarding unauthorize update of obj %s from %s", obj_id, actor.fid
|
||||
)
|
||||
return
|
||||
|
||||
serializer = serializer_class(obj, data=payload["object"])
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
else:
|
||||
logger.debug(
|
||||
"Discarding update of obj %s because of payload errors: %s",
|
||||
obj_id,
|
||||
serializer.errors,
|
||||
)
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Track"})
|
||||
def inbox_update_track(payload, context):
|
||||
return handle_library_entry_update(
|
||||
payload,
|
||||
context,
|
||||
queryset=music_models.Track.objects.all(),
|
||||
serializer_class=serializers.TrackSerializer,
|
||||
)
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Artist"})
|
||||
def inbox_update_artist(payload, context):
|
||||
return handle_library_entry_update(
|
||||
payload,
|
||||
context,
|
||||
queryset=music_models.Artist.objects.all(),
|
||||
serializer_class=serializers.ArtistSerializer,
|
||||
)
|
||||
|
||||
|
||||
@inbox.register({"type": "Update", "object.type": "Album"})
|
||||
def inbox_update_album(payload, context):
|
||||
return handle_library_entry_update(
|
||||
payload,
|
||||
context,
|
||||
queryset=music_models.Album.objects.all(),
|
||||
serializer_class=serializers.AlbumSerializer,
|
||||
)
|
||||
|
||||
|
||||
@outbox.register({"type": "Update", "object.type": "Track"})
|
||||
def outbox_update_track(context):
|
||||
track = context["track"]
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Update", "object": serializers.TrackSerializer(track).data}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Update",
|
||||
"actor": actors.get_service_actor(),
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
|
|
@ -7,9 +7,11 @@ from django.core.paginator import Paginator
|
|||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import utils as funkwhale_utils
|
||||
from funkwhale_api.music import licenses
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import tasks as music_tasks
|
||||
|
||||
from . import activity, contexts, jsonld, models, utils
|
||||
from . import activity, actors, contexts, jsonld, models, utils
|
||||
|
||||
AP_CONTEXT = jsonld.get_default_context()
|
||||
|
||||
|
@ -670,7 +672,7 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
|
|||
"first": jsonld.first_id(contexts.AS.first),
|
||||
"last": jsonld.first_id(contexts.AS.last),
|
||||
"next": jsonld.first_id(contexts.AS.next),
|
||||
"prev": jsonld.first_id(contexts.AS.next),
|
||||
"prev": jsonld.first_id(contexts.AS.prev),
|
||||
"partOf": jsonld.first_id(contexts.AS.partOf),
|
||||
}
|
||||
|
||||
|
@ -731,6 +733,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
|
|||
"name": jsonld.first_val(contexts.AS.name),
|
||||
"published": jsonld.first_val(contexts.AS.published),
|
||||
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
|
||||
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
|
||||
}
|
||||
|
||||
|
||||
|
@ -739,9 +742,29 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
|
|||
published = serializers.DateTimeField()
|
||||
musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
|
||||
name = serializers.CharField(max_length=1000)
|
||||
attributedTo = serializers.URLField(max_length=500, allow_null=True, required=False)
|
||||
updateable_fields = []
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
attributed_to_fid = validated_data.get("attributedTo")
|
||||
if attributed_to_fid:
|
||||
validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
|
||||
updated_fields = funkwhale_utils.get_updated_fields(
|
||||
self.updateable_fields, validated_data, instance
|
||||
)
|
||||
if updated_fields:
|
||||
return music_tasks.update_library_entity(instance, updated_fields)
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class ArtistSerializer(MusicEntitySerializer):
|
||||
updateable_fields = [
|
||||
("name", "name"),
|
||||
("musicbrainzId", "mbid"),
|
||||
("attributedTo", "attributed_to"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
|
||||
|
||||
|
@ -752,6 +775,9 @@ class ArtistSerializer(MusicEntitySerializer):
|
|||
"name": instance.name,
|
||||
"published": instance.creation_date.isoformat(),
|
||||
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
|
||||
"attributedTo": instance.attributed_to.fid
|
||||
if instance.attributed_to
|
||||
else None,
|
||||
}
|
||||
|
||||
if self.context.get("include_ap_context", self.parent is None):
|
||||
|
@ -765,6 +791,12 @@ class AlbumSerializer(MusicEntitySerializer):
|
|||
cover = LinkSerializer(
|
||||
allowed_mimetypes=["image/*"], allow_null=True, required=False
|
||||
)
|
||||
updateable_fields = [
|
||||
("name", "title"),
|
||||
("musicbrainzId", "mbid"),
|
||||
("attributedTo", "attributed_to"),
|
||||
("released", "release_date"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = funkwhale_utils.concat_dicts(
|
||||
|
@ -791,6 +823,9 @@ class AlbumSerializer(MusicEntitySerializer):
|
|||
instance.artist, context={"include_ap_context": False}
|
||||
).data
|
||||
],
|
||||
"attributedTo": instance.attributed_to.fid
|
||||
if instance.attributed_to
|
||||
else None,
|
||||
}
|
||||
if instance.cover:
|
||||
d["cover"] = {
|
||||
|
@ -812,6 +847,16 @@ class TrackSerializer(MusicEntitySerializer):
|
|||
license = serializers.URLField(allow_null=True, required=False)
|
||||
copyright = serializers.CharField(allow_null=True, required=False)
|
||||
|
||||
updateable_fields = [
|
||||
("name", "title"),
|
||||
("musicbrainzId", "mbid"),
|
||||
("attributedTo", "attributed_to"),
|
||||
("disc", "disc_number"),
|
||||
("position", "position"),
|
||||
("copyright", "copyright"),
|
||||
("license", "license"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = funkwhale_utils.concat_dicts(
|
||||
MUSIC_ENTITY_JSONLD_MAPPING,
|
||||
|
@ -846,6 +891,9 @@ class TrackSerializer(MusicEntitySerializer):
|
|||
"album": AlbumSerializer(
|
||||
instance.album, context={"include_ap_context": False}
|
||||
).data,
|
||||
"attributedTo": instance.attributed_to.fid
|
||||
if instance.attributed_to
|
||||
else None,
|
||||
}
|
||||
|
||||
if self.context.get("include_ap_context", self.parent is None):
|
||||
|
@ -855,13 +903,53 @@ class TrackSerializer(MusicEntitySerializer):
|
|||
def create(self, validated_data):
|
||||
from funkwhale_api.music import tasks as music_tasks
|
||||
|
||||
metadata = music_tasks.federation_audio_track_to_metadata(validated_data)
|
||||
references = {}
|
||||
actors_to_fetch = set()
|
||||
actors_to_fetch.add(
|
||||
funkwhale_utils.recursive_getattr(
|
||||
validated_data, "attributedTo", permissive=True
|
||||
)
|
||||
)
|
||||
actors_to_fetch.add(
|
||||
funkwhale_utils.recursive_getattr(
|
||||
validated_data, "album.attributedTo", permissive=True
|
||||
)
|
||||
)
|
||||
artists = (
|
||||
funkwhale_utils.recursive_getattr(
|
||||
validated_data, "artists", permissive=True
|
||||
)
|
||||
or []
|
||||
)
|
||||
album_artists = (
|
||||
funkwhale_utils.recursive_getattr(
|
||||
validated_data, "album.artists", permissive=True
|
||||
)
|
||||
or []
|
||||
)
|
||||
for artist in artists + album_artists:
|
||||
actors_to_fetch.add(artist.get("attributedTo"))
|
||||
|
||||
for url in actors_to_fetch:
|
||||
if not url:
|
||||
continue
|
||||
references[url] = actors.get_actor(url)
|
||||
|
||||
metadata = music_tasks.federation_audio_track_to_metadata(
|
||||
validated_data, references
|
||||
)
|
||||
|
||||
from_activity = self.context.get("activity")
|
||||
if from_activity:
|
||||
metadata["from_activity_id"] = from_activity.pk
|
||||
track = music_tasks.get_track_from_import_metadata(metadata, update_cover=True)
|
||||
return track
|
||||
|
||||
def update(self, obj, validated_data):
|
||||
if validated_data.get("license"):
|
||||
validated_data["license"] = licenses.match(validated_data["license"])
|
||||
return super().update(obj, validated_data)
|
||||
|
||||
|
||||
class UploadSerializer(jsonld.JsonLdSerializer):
|
||||
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
|
||||
|
|
|
@ -64,6 +64,12 @@ class ArtistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
class Meta:
|
||||
model = "music.Artist"
|
||||
|
||||
class Params:
|
||||
attributed = factory.Trait(
|
||||
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
|
||||
)
|
||||
local = factory.Trait(fid=factory.Faker("federation_url", local=True))
|
||||
|
||||
|
||||
@registry.register
|
||||
class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
|
@ -79,6 +85,15 @@ class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
class Meta:
|
||||
model = "music.Album"
|
||||
|
||||
class Params:
|
||||
attributed = factory.Trait(
|
||||
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
|
||||
)
|
||||
|
||||
local = factory.Trait(
|
||||
fid=factory.Faker("federation_url", local=True), artist__local=True
|
||||
)
|
||||
|
||||
|
||||
@registry.register
|
||||
class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
|
@ -94,6 +109,15 @@ class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
class Meta:
|
||||
model = "music.Track"
|
||||
|
||||
class Params:
|
||||
attributed = factory.Trait(
|
||||
attributed_to=factory.SubFactory(federation_factories.ActorFactory)
|
||||
)
|
||||
|
||||
local = factory.Trait(
|
||||
fid=factory.Faker("federation_url", local=True), album__local=True
|
||||
)
|
||||
|
||||
@factory.post_generation
|
||||
def license(self, created, extracted, **kwargs):
|
||||
if not created:
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
# Generated by Django 2.1.7 on 2019-04-09 09:33
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("federation", "0017_auto_20190130_0926"),
|
||||
("music", "0037_auto_20190103_1757"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="artist",
|
||||
name="attributed_to",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="attributed_artists",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="album",
|
||||
name="attributed_to",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="attributed_albums",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="track",
|
||||
name="attributed_to",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="attributed_tracks",
|
||||
to="federation.Actor",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -114,6 +114,16 @@ class APIModelMixin(models.Model):
|
|||
|
||||
return super().save(**kwargs)
|
||||
|
||||
@property
|
||||
def is_local(self):
|
||||
if not self.fid:
|
||||
return True
|
||||
|
||||
d = settings.FEDERATION_HOSTNAME
|
||||
return self.fid.startswith("http://{}/".format(d)) or self.fid.startswith(
|
||||
"https://{}/".format(d)
|
||||
)
|
||||
|
||||
|
||||
class License(models.Model):
|
||||
code = models.CharField(primary_key=True, max_length=100)
|
||||
|
@ -178,6 +188,16 @@ class Artist(APIModelMixin):
|
|||
"mbid": {"musicbrainz_field_name": "id"},
|
||||
"name": {"musicbrainz_field_name": "name"},
|
||||
}
|
||||
# Music entities are attributed to actors, to validate that updates occur
|
||||
# from an authorized account. On top of that, we consider the instance actor
|
||||
# can update anything under it's own domain
|
||||
attributed_to = models.ForeignKey(
|
||||
"federation.Actor",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="attributed_artists",
|
||||
)
|
||||
api = musicbrainz.api.artists
|
||||
objects = ArtistQuerySet.as_manager()
|
||||
|
||||
|
@ -254,6 +274,16 @@ class Album(APIModelMixin):
|
|||
TYPE_CHOICES = (("album", "Album"),)
|
||||
type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")
|
||||
|
||||
# Music entities are attributed to actors, to validate that updates occur
|
||||
# from an authorized account. On top of that, we consider the instance actor
|
||||
# can update anything under it's own domain
|
||||
attributed_to = models.ForeignKey(
|
||||
"federation.Actor",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="attributed_albums",
|
||||
)
|
||||
api_includes = ["artist-credits", "recordings", "media", "release-groups"]
|
||||
api = musicbrainz.api.releases
|
||||
federation_namespace = "albums"
|
||||
|
@ -476,6 +506,16 @@ class Track(APIModelMixin):
|
|||
on_delete=models.DO_NOTHING,
|
||||
related_name="tracks",
|
||||
)
|
||||
# Music entities are attributed to actors, to validate that updates occur
|
||||
# from an authorized account. On top of that, we consider the instance actor
|
||||
# can update anything under it's own domain
|
||||
attributed_to = models.ForeignKey(
|
||||
"federation.Actor",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="attributed_tracks",
|
||||
)
|
||||
copyright = models.CharField(max_length=500, null=True, blank=True)
|
||||
federation_namespace = "tracks"
|
||||
musicbrainz_model = "recording"
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
from funkwhale_api.common import mutations
|
||||
from funkwhale_api.federation import routes
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
def can_suggest(obj, actor):
|
||||
return True
|
||||
return obj.is_local
|
||||
|
||||
|
||||
def can_approve(obj, actor):
|
||||
return actor.user and actor.user.get_permissions()["library"]
|
||||
return obj.is_local and actor.user and actor.user.get_permissions()["library"]
|
||||
|
||||
|
||||
@mutations.registry.connect(
|
||||
|
@ -22,3 +23,8 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer):
|
|||
class Meta:
|
||||
model = models.Track
|
||||
fields = ["license", "title", "position", "copyright"]
|
||||
|
||||
def post_apply(self, obj, validated_data):
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Update", "object": {"type": "Track"}}, context={"track": obj}
|
||||
)
|
||||
|
|
|
@ -43,6 +43,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer):
|
|||
model = models.Album
|
||||
fields = (
|
||||
"id",
|
||||
"fid",
|
||||
"mbid",
|
||||
"title",
|
||||
"artist",
|
||||
|
@ -51,6 +52,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer):
|
|||
"creation_date",
|
||||
"tracks_count",
|
||||
"is_playable",
|
||||
"is_local",
|
||||
)
|
||||
|
||||
def get_tracks_count(self, o):
|
||||
|
@ -68,13 +70,13 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = models.Artist
|
||||
fields = ("id", "mbid", "name", "creation_date", "albums")
|
||||
fields = ("id", "fid", "mbid", "name", "creation_date", "albums", "is_local")
|
||||
|
||||
|
||||
class ArtistSimpleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Artist
|
||||
fields = ("id", "mbid", "name", "creation_date")
|
||||
fields = ("id", "fid", "mbid", "name", "creation_date", "is_local")
|
||||
|
||||
|
||||
class AlbumTrackSerializer(serializers.ModelSerializer):
|
||||
|
@ -87,6 +89,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
|
|||
model = models.Track
|
||||
fields = (
|
||||
"id",
|
||||
"fid",
|
||||
"mbid",
|
||||
"title",
|
||||
"album",
|
||||
|
@ -99,6 +102,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
|
|||
"duration",
|
||||
"copyright",
|
||||
"license",
|
||||
"is_local",
|
||||
)
|
||||
|
||||
def get_uploads(self, obj):
|
||||
|
@ -125,6 +129,7 @@ class AlbumSerializer(serializers.ModelSerializer):
|
|||
model = models.Album
|
||||
fields = (
|
||||
"id",
|
||||
"fid",
|
||||
"mbid",
|
||||
"title",
|
||||
"artist",
|
||||
|
@ -133,6 +138,7 @@ class AlbumSerializer(serializers.ModelSerializer):
|
|||
"cover",
|
||||
"creation_date",
|
||||
"is_playable",
|
||||
"is_local",
|
||||
)
|
||||
|
||||
def get_tracks(self, o):
|
||||
|
@ -156,12 +162,14 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
|
|||
model = models.Album
|
||||
fields = (
|
||||
"id",
|
||||
"fid",
|
||||
"mbid",
|
||||
"title",
|
||||
"artist",
|
||||
"release_date",
|
||||
"cover",
|
||||
"creation_date",
|
||||
"is_local",
|
||||
)
|
||||
|
||||
|
||||
|
@ -190,6 +198,7 @@ class TrackSerializer(serializers.ModelSerializer):
|
|||
model = models.Track
|
||||
fields = (
|
||||
"id",
|
||||
"fid",
|
||||
"mbid",
|
||||
"title",
|
||||
"album",
|
||||
|
@ -202,6 +211,7 @@ class TrackSerializer(serializers.ModelSerializer):
|
|||
"listen_url",
|
||||
"copyright",
|
||||
"license",
|
||||
"is_local",
|
||||
)
|
||||
|
||||
def get_lyrics(self, obj):
|
||||
|
|
|
@ -206,7 +206,9 @@ def process_upload(upload):
|
|||
)
|
||||
additional_data["upload_source"] = upload.source
|
||||
try:
|
||||
track = get_track_from_import_metadata(final_metadata)
|
||||
track = get_track_from_import_metadata(
|
||||
final_metadata, attributed_to=upload.library.actor
|
||||
)
|
||||
except UploadImportError as e:
|
||||
return fail_import(upload, e.code)
|
||||
except Exception:
|
||||
|
@ -282,7 +284,7 @@ def process_upload(upload):
|
|||
)
|
||||
|
||||
|
||||
def federation_audio_track_to_metadata(payload):
|
||||
def federation_audio_track_to_metadata(payload, references):
|
||||
"""
|
||||
Given a valid payload as returned by federation.serializers.TrackSerializer.validated_data,
|
||||
returns a correct metadata payload for use with get_track_from_import_metadata.
|
||||
|
@ -293,6 +295,7 @@ def federation_audio_track_to_metadata(payload):
|
|||
"disc_number": payload.get("disc"),
|
||||
"license": payload.get("license"),
|
||||
"copyright": payload.get("copyright"),
|
||||
"attributed_to": references.get(payload.get("attributedTo")),
|
||||
"mbid": str(payload.get("musicbrainzId"))
|
||||
if payload.get("musicbrainzId")
|
||||
else None,
|
||||
|
@ -300,6 +303,7 @@ def federation_audio_track_to_metadata(payload):
|
|||
"title": payload["album"]["name"],
|
||||
"fdate": payload["album"]["published"],
|
||||
"fid": payload["album"]["id"],
|
||||
"attributed_to": references.get(payload["album"].get("attributedTo")),
|
||||
"mbid": str(payload["album"]["musicbrainzId"])
|
||||
if payload["album"].get("musicbrainzId")
|
||||
else None,
|
||||
|
@ -309,6 +313,7 @@ def federation_audio_track_to_metadata(payload):
|
|||
"fid": a["id"],
|
||||
"name": a["name"],
|
||||
"fdate": a["published"],
|
||||
"attributed_to": references.get(a.get("attributedTo")),
|
||||
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
|
||||
}
|
||||
for a in payload["album"]["artists"]
|
||||
|
@ -319,6 +324,7 @@ def federation_audio_track_to_metadata(payload):
|
|||
"fid": a["id"],
|
||||
"name": a["name"],
|
||||
"fdate": a["published"],
|
||||
"attributed_to": references.get(a.get("attributedTo")),
|
||||
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
|
||||
}
|
||||
for a in payload["artists"]
|
||||
|
@ -393,8 +399,8 @@ def sort_candidates(candidates, important_fields):
|
|||
|
||||
|
||||
@transaction.atomic
|
||||
def get_track_from_import_metadata(data, update_cover=False):
|
||||
track = _get_track(data)
|
||||
def get_track_from_import_metadata(data, update_cover=False, attributed_to=None):
|
||||
track = _get_track(data, attributed_to=attributed_to)
|
||||
if update_cover and track and not track.album.cover:
|
||||
update_album_cover(
|
||||
track.album,
|
||||
|
@ -404,7 +410,7 @@ def get_track_from_import_metadata(data, update_cover=False):
|
|||
return track
|
||||
|
||||
|
||||
def _get_track(data):
|
||||
def _get_track(data, attributed_to=None):
|
||||
track_uuid = getter(data, "funkwhale", "track", "uuid")
|
||||
|
||||
if track_uuid:
|
||||
|
@ -458,6 +464,7 @@ def _get_track(data):
|
|||
"mbid": artist_mbid,
|
||||
"fid": artist_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": artist.get("attributed_to", attributed_to),
|
||||
}
|
||||
if artist.get("fdate"):
|
||||
defaults["creation_date"] = artist.get("fdate")
|
||||
|
@ -484,6 +491,7 @@ def _get_track(data):
|
|||
"mbid": album_artist_mbid,
|
||||
"fid": album_artist_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": album_artist.get("attributed_to", attributed_to),
|
||||
}
|
||||
if album_artist.get("fdate"):
|
||||
defaults["creation_date"] = album_artist.get("fdate")
|
||||
|
@ -511,6 +519,7 @@ def _get_track(data):
|
|||
"release_date": album.get("release_date"),
|
||||
"fid": album_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": album.get("attributed_to", attributed_to),
|
||||
}
|
||||
if album.get("fdate"):
|
||||
defaults["creation_date"] = album.get("fdate")
|
||||
|
@ -536,6 +545,7 @@ def _get_track(data):
|
|||
"disc_number": data.get("disc_number"),
|
||||
"fid": track_fid,
|
||||
"from_activity_id": from_activity_id,
|
||||
"attributed_to": data.get("attributed_to", attributed_to),
|
||||
"license": licenses.match(data.get("license"), data.get("copyright")),
|
||||
"copyright": data.get("copyright"),
|
||||
}
|
||||
|
@ -613,3 +623,18 @@ def get_prunable_albums():
|
|||
|
||||
def get_prunable_artists():
|
||||
return models.Artist.objects.filter(tracks__isnull=True, albums__isnull=True)
|
||||
|
||||
|
||||
def update_library_entity(obj, data):
|
||||
"""
|
||||
Given an obj and some updated fields, will persist the changes on the obj
|
||||
and also check if the entity need to be aliased with existing objs (i.e
|
||||
if a mbid was added on the obj, and match another entity with the same mbid)
|
||||
"""
|
||||
for key, value in data.items():
|
||||
setattr(obj, key, value)
|
||||
|
||||
# Todo: handle integrity error on unique fields (such as MBID)
|
||||
obj.save(update_fields=list(data.keys()))
|
||||
|
||||
return obj
|
||||
|
|
|
@ -44,7 +44,7 @@ def test_mutations_route_create_success(factories, api_request, is_approved, moc
|
|||
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
|
||||
user = factories["users.User"](permission_library=True)
|
||||
actor = user.create_actor()
|
||||
track = factories["music.Track"](title="foo")
|
||||
track = factories["music.Track"](title="foo", local=True)
|
||||
view = V.as_view({"post": "mutations"})
|
||||
|
||||
request = api_request.post(
|
||||
|
|
|
@ -10,7 +10,7 @@ def mutations_registry():
|
|||
return mutations.Registry()
|
||||
|
||||
|
||||
def test_apply_mutation(mutations_registry):
|
||||
def test_apply_mutation(mutations_registry, db):
|
||||
class Obj:
|
||||
pass
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
from funkwhale_api.common import utils
|
||||
|
||||
|
||||
|
@ -42,3 +44,44 @@ def test_update_prefix(factories):
|
|||
old = n.fid
|
||||
n.refresh_from_db()
|
||||
assert n.fid == old.replace("http://", "https://")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"conf, mock_args, data, expected",
|
||||
[
|
||||
(
|
||||
["field1", "field2"],
|
||||
{"field1": "foo", "field2": "test"},
|
||||
{"field1": "bar"},
|
||||
{"field1": "bar"},
|
||||
),
|
||||
(
|
||||
["field1", "field2"],
|
||||
{"field1": "foo", "field2": "test"},
|
||||
{"field1": "foo"},
|
||||
{},
|
||||
),
|
||||
(
|
||||
["field1", "field2"],
|
||||
{"field1": "foo", "field2": "test"},
|
||||
{"field1": "foo", "field2": "test"},
|
||||
{},
|
||||
),
|
||||
(
|
||||
["field1", "field2"],
|
||||
{"field1": "foo", "field2": "test"},
|
||||
{"field1": "bar", "field2": "test1"},
|
||||
{"field1": "bar", "field2": "test1"},
|
||||
),
|
||||
(
|
||||
[("field1", "Hello"), ("field2", "World")],
|
||||
{"Hello": "foo", "World": "test"},
|
||||
{"field1": "bar", "field2": "test1"},
|
||||
{"Hello": "bar", "World": "test1"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_updated_fields(conf, mock_args, data, expected, mocker):
|
||||
obj = mocker.Mock(**mock_args)
|
||||
|
||||
assert utils.get_updated_fields(conf, data, obj) == expected
|
||||
|
|
|
@ -436,6 +436,53 @@ def test_prepare_deliveries_and_inbox_items(factories):
|
|||
assert inbox_item.type == "to"
|
||||
|
||||
|
||||
def test_prepare_deliveries_and_inbox_items_instances_with_followers(factories):
|
||||
|
||||
domain1 = factories["federation.Domain"](with_service_actor=True)
|
||||
domain2 = factories["federation.Domain"](with_service_actor=True)
|
||||
library = factories["music.Library"](actor__local=True)
|
||||
|
||||
factories["federation.LibraryFollow"](
|
||||
target=library, actor__local=True, approved=True
|
||||
).actor
|
||||
library_follower_remote = factories["federation.LibraryFollow"](
|
||||
target=library, actor__domain=domain1, approved=True
|
||||
).actor
|
||||
|
||||
followed_actor = factories["federation.Actor"](local=True)
|
||||
factories["federation.Follow"](
|
||||
target=followed_actor, actor__local=True, approved=True
|
||||
).actor
|
||||
actor_follower_remote = factories["federation.Follow"](
|
||||
target=followed_actor, actor__domain=domain2, approved=True
|
||||
).actor
|
||||
|
||||
recipients = [activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}]
|
||||
|
||||
inbox_items, deliveries, urls = activity.prepare_deliveries_and_inbox_items(
|
||||
recipients, "to"
|
||||
)
|
||||
|
||||
expected_deliveries = sorted(
|
||||
[
|
||||
models.Delivery(
|
||||
inbox_url=library_follower_remote.domain.service_actor.inbox_url
|
||||
),
|
||||
models.Delivery(
|
||||
inbox_url=actor_follower_remote.domain.service_actor.inbox_url
|
||||
),
|
||||
],
|
||||
key=lambda v: v.inbox_url,
|
||||
)
|
||||
assert inbox_items == []
|
||||
assert len(expected_deliveries) == len(deliveries)
|
||||
|
||||
for delivery, expected_delivery in zip(
|
||||
sorted(deliveries, key=lambda v: v.inbox_url), expected_deliveries
|
||||
):
|
||||
assert delivery.inbox_url == expected_delivery.inbox_url
|
||||
|
||||
|
||||
def test_should_rotate_actor_key(settings, cache, now):
|
||||
actor_id = 42
|
||||
settings.ACTOR_KEY_ROTATION_DELAY = 10
|
||||
|
|
|
@ -134,3 +134,33 @@ def test_actor_stats(factories):
|
|||
actor = factories["federation.Actor"]()
|
||||
|
||||
assert actor.get_stats() == expected
|
||||
|
||||
|
||||
def test_actor_can_manage_false(mocker, factories):
|
||||
obj = mocker.Mock()
|
||||
actor = factories["federation.Actor"]()
|
||||
|
||||
assert actor.can_manage(obj) is False
|
||||
|
||||
|
||||
def test_actor_can_manage_attributed_to(mocker, factories):
|
||||
actor = factories["federation.Actor"]()
|
||||
obj = mocker.Mock(attributed_to_id=actor.pk)
|
||||
|
||||
assert actor.can_manage(obj) is True
|
||||
|
||||
|
||||
def test_actor_can_manage_domain_not_service_actor(mocker, factories):
|
||||
actor = factories["federation.Actor"]()
|
||||
obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id))
|
||||
|
||||
assert actor.can_manage(obj) is False
|
||||
|
||||
|
||||
def test_actor_can_manage_domain_service_actor(mocker, factories):
|
||||
actor = factories["federation.Actor"]()
|
||||
actor.domain.service_actor = actor
|
||||
actor.domain.save()
|
||||
obj = mocker.Mock(fid="https://{}/hello".format(actor.domain_id))
|
||||
|
||||
assert actor.can_manage(obj) is True
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import pytest
|
||||
|
||||
from funkwhale_api.federation import jsonld, routes, serializers
|
||||
from funkwhale_api.federation import actors, contexts, jsonld, routes, serializers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -13,6 +13,9 @@ from funkwhale_api.federation import jsonld, routes, serializers
|
|||
({"type": "Delete", "object.type": "Library"}, routes.inbox_delete_library),
|
||||
({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio),
|
||||
({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow),
|
||||
({"type": "Update", "object.type": "Artist"}, routes.inbox_update_artist),
|
||||
({"type": "Update", "object.type": "Album"}, routes.inbox_update_album),
|
||||
({"type": "Update", "object.type": "Track"}, routes.inbox_update_track),
|
||||
],
|
||||
)
|
||||
def test_inbox_routes(route, handler):
|
||||
|
@ -34,6 +37,7 @@ def test_inbox_routes(route, handler):
|
|||
({"type": "Delete", "object.type": "Library"}, routes.outbox_delete_library),
|
||||
({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio),
|
||||
({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow),
|
||||
({"type": "Update", "object.type": "Track"}, routes.outbox_update_track),
|
||||
],
|
||||
)
|
||||
def test_outbox_routes(route, handler):
|
||||
|
@ -405,3 +409,89 @@ def test_outbox_delete_follow_library(factories):
|
|||
assert activity["actor"] == follow.actor
|
||||
assert activity["object"] == follow
|
||||
assert activity["related_object"] == follow.target
|
||||
|
||||
|
||||
def test_handle_library_entry_update_can_manage(factories, mocker):
|
||||
update_library_entity = mocker.patch(
|
||||
"funkwhale_api.music.tasks.update_library_entity"
|
||||
)
|
||||
activity = factories["federation.Activity"]()
|
||||
obj = factories["music.Artist"]()
|
||||
actor = factories["federation.Actor"]()
|
||||
mocker.patch.object(actor, "can_manage", return_value=False)
|
||||
data = serializers.ArtistSerializer(obj).data
|
||||
data["name"] = "New name"
|
||||
payload = {"type": "Update", "actor": actor, "object": data}
|
||||
|
||||
routes.inbox_update_artist(
|
||||
payload, context={"actor": actor, "raise_exception": True, "activity": activity}
|
||||
)
|
||||
|
||||
update_library_entity.assert_not_called()
|
||||
|
||||
|
||||
def test_inbox_update_artist(factories, mocker):
|
||||
update_library_entity = mocker.patch(
|
||||
"funkwhale_api.music.tasks.update_library_entity"
|
||||
)
|
||||
activity = factories["federation.Activity"]()
|
||||
obj = factories["music.Artist"](attributed=True)
|
||||
actor = obj.attributed_to
|
||||
data = serializers.ArtistSerializer(obj).data
|
||||
data["name"] = "New name"
|
||||
payload = {"type": "Update", "actor": actor, "object": data}
|
||||
|
||||
routes.inbox_update_artist(
|
||||
payload, context={"actor": actor, "raise_exception": True, "activity": activity}
|
||||
)
|
||||
|
||||
update_library_entity.assert_called_once_with(obj, {"name": "New name"})
|
||||
|
||||
|
||||
def test_inbox_update_album(factories, mocker):
|
||||
update_library_entity = mocker.patch(
|
||||
"funkwhale_api.music.tasks.update_library_entity"
|
||||
)
|
||||
activity = factories["federation.Activity"]()
|
||||
obj = factories["music.Album"](attributed=True)
|
||||
actor = obj.attributed_to
|
||||
data = serializers.AlbumSerializer(obj).data
|
||||
data["name"] = "New title"
|
||||
payload = {"type": "Update", "actor": actor, "object": data}
|
||||
|
||||
routes.inbox_update_album(
|
||||
payload, context={"actor": actor, "raise_exception": True, "activity": activity}
|
||||
)
|
||||
|
||||
update_library_entity.assert_called_once_with(obj, {"title": "New title"})
|
||||
|
||||
|
||||
def test_inbox_update_track(factories, mocker):
|
||||
update_library_entity = mocker.patch(
|
||||
"funkwhale_api.music.tasks.update_library_entity"
|
||||
)
|
||||
activity = factories["federation.Activity"]()
|
||||
obj = factories["music.Track"](attributed=True)
|
||||
actor = obj.attributed_to
|
||||
data = serializers.TrackSerializer(obj).data
|
||||
data["name"] = "New title"
|
||||
payload = {"type": "Update", "actor": actor, "object": data}
|
||||
|
||||
routes.inbox_update_track(
|
||||
payload, context={"actor": actor, "raise_exception": True, "activity": activity}
|
||||
)
|
||||
|
||||
update_library_entity.assert_called_once_with(obj, {"title": "New title"})
|
||||
|
||||
|
||||
def test_outbox_update_track(factories):
|
||||
track = factories["music.Track"]()
|
||||
activity = list(routes.outbox_update_track({"track": track}))[0]
|
||||
expected = serializers.ActivitySerializer(
|
||||
{"type": "Update", "object": serializers.TrackSerializer(track).data}
|
||||
).data
|
||||
|
||||
expected["to"] = [contexts.AS.Public, {"type": "instances_with_followers"}]
|
||||
|
||||
assert dict(activity["payload"]) == dict(expected)
|
||||
assert activity["actor"] == actors.get_service_actor()
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
import io
|
||||
import pytest
|
||||
import uuid
|
||||
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.federation import keys
|
||||
from funkwhale_api.federation import jsonld
|
||||
from funkwhale_api.federation import models
|
||||
from funkwhale_api.federation import serializers
|
||||
from funkwhale_api.federation import utils
|
||||
from funkwhale_api.music import licenses
|
||||
|
||||
|
||||
def test_actor_serializer_from_ap(db):
|
||||
private, public = keys.get_key_pair()
|
||||
actor_url = "https://test.federation/actor"
|
||||
payload = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"@context": jsonld.get_default_context_fw(),
|
||||
"id": actor_url,
|
||||
"type": "Person",
|
||||
"outbox": "https://test.com/outbox",
|
||||
|
@ -47,3 +57,864 @@ def test_actor_serializer_from_ap(db):
|
|||
assert actor.private_key is None
|
||||
assert actor.public_key == payload["publicKey"]["publicKeyPem"]
|
||||
assert actor.domain_id == "test.federation"
|
||||
|
||||
|
||||
def test_actor_serializer_only_mandatory_field_from_ap(db):
|
||||
payload = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"id": "https://test.federation/user",
|
||||
"type": "Person",
|
||||
"following": "https://test.federation/user/following",
|
||||
"followers": "https://test.federation/user/followers",
|
||||
"inbox": "https://test.federation/user/inbox",
|
||||
"outbox": "https://test.federation/user/outbox",
|
||||
"preferredUsername": "user",
|
||||
}
|
||||
|
||||
serializer = serializers.ActorSerializer(data=payload)
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
actor = serializer.build()
|
||||
|
||||
assert actor.fid == payload["id"]
|
||||
assert actor.inbox_url == payload["inbox"]
|
||||
assert actor.outbox_url == payload["outbox"]
|
||||
assert actor.followers_url == payload["followers"]
|
||||
assert actor.following_url == payload["following"]
|
||||
assert actor.preferred_username == payload["preferredUsername"]
|
||||
assert actor.domain.pk == "test.federation"
|
||||
assert actor.type == "Person"
|
||||
assert actor.manually_approves_followers is None
|
||||
|
||||
|
||||
def test_actor_serializer_to_ap():
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"id": "https://test.federation/user",
|
||||
"type": "Person",
|
||||
"following": "https://test.federation/user/following",
|
||||
"followers": "https://test.federation/user/followers",
|
||||
"inbox": "https://test.federation/user/inbox",
|
||||
"outbox": "https://test.federation/user/outbox",
|
||||
"preferredUsername": "user",
|
||||
"name": "Real User",
|
||||
"summary": "Hello world",
|
||||
"manuallyApprovesFollowers": False,
|
||||
"publicKey": {
|
||||
"id": "https://test.federation/user#main-key",
|
||||
"owner": "https://test.federation/user",
|
||||
"publicKeyPem": "yolo",
|
||||
},
|
||||
"endpoints": {"sharedInbox": "https://test.federation/inbox"},
|
||||
}
|
||||
ac = models.Actor(
|
||||
fid=expected["id"],
|
||||
inbox_url=expected["inbox"],
|
||||
outbox_url=expected["outbox"],
|
||||
shared_inbox_url=expected["endpoints"]["sharedInbox"],
|
||||
followers_url=expected["followers"],
|
||||
following_url=expected["following"],
|
||||
public_key=expected["publicKey"]["publicKeyPem"],
|
||||
preferred_username=expected["preferredUsername"],
|
||||
name=expected["name"],
|
||||
domain=models.Domain(pk="test.federation"),
|
||||
summary=expected["summary"],
|
||||
type="Person",
|
||||
manually_approves_followers=False,
|
||||
)
|
||||
serializer = serializers.ActorSerializer(ac)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_webfinger_serializer():
|
||||
expected = {
|
||||
"subject": "acct:service@test.federation",
|
||||
"links": [
|
||||
{
|
||||
"rel": "self",
|
||||
"href": "https://test.federation/federation/instance/actor",
|
||||
"type": "application/activity+json",
|
||||
}
|
||||
],
|
||||
"aliases": ["https://test.federation/federation/instance/actor"],
|
||||
}
|
||||
actor = models.Actor(
|
||||
fid=expected["links"][0]["href"],
|
||||
preferred_username="service",
|
||||
domain=models.Domain(pk="test.federation"),
|
||||
)
|
||||
serializer = serializers.ActorWebfingerSerializer(actor)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_follow_serializer_to_ap(factories):
|
||||
follow = factories["federation.Follow"](local=True)
|
||||
serializer = serializers.FollowSerializer(follow)
|
||||
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"id": follow.get_federation_id(),
|
||||
"type": "Follow",
|
||||
"actor": follow.actor.fid,
|
||||
"object": follow.target.fid,
|
||||
}
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_follow_serializer_save(factories):
|
||||
actor = factories["federation.Actor"]()
|
||||
target = factories["federation.Actor"]()
|
||||
|
||||
data = {
|
||||
"id": "https://test.follow",
|
||||
"type": "Follow",
|
||||
"actor": actor.fid,
|
||||
"object": target.fid,
|
||||
}
|
||||
serializer = serializers.FollowSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
follow = serializer.save()
|
||||
|
||||
assert follow.pk is not None
|
||||
assert follow.actor == actor
|
||||
assert follow.target == target
|
||||
assert follow.approved is None
|
||||
|
||||
|
||||
def test_follow_serializer_save_validates_on_context(factories):
|
||||
actor = factories["federation.Actor"]()
|
||||
target = factories["federation.Actor"]()
|
||||
impostor = factories["federation.Actor"]()
|
||||
|
||||
data = {
|
||||
"id": "https://test.follow",
|
||||
"type": "Follow",
|
||||
"actor": actor.fid,
|
||||
"object": target.fid,
|
||||
}
|
||||
serializer = serializers.FollowSerializer(
|
||||
data=data, context={"follow_actor": impostor, "follow_target": impostor}
|
||||
)
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
|
||||
assert "actor" in serializer.errors
|
||||
assert "object" in serializer.errors
|
||||
|
||||
|
||||
def test_accept_follow_serializer_representation(factories):
|
||||
follow = factories["federation.Follow"](approved=None)
|
||||
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"id": follow.get_federation_id() + "/accept",
|
||||
"type": "Accept",
|
||||
"actor": follow.target.fid,
|
||||
"object": serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.AcceptFollowSerializer(follow)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_accept_follow_serializer_save(factories):
|
||||
follow = factories["federation.Follow"](approved=None)
|
||||
|
||||
data = {
|
||||
"@context": jsonld.get_default_context_fw(),
|
||||
"id": follow.get_federation_id() + "/accept",
|
||||
"type": "Accept",
|
||||
"actor": follow.target.fid,
|
||||
"object": serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.AcceptFollowSerializer(data=data)
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
follow.refresh_from_db()
|
||||
|
||||
assert follow.approved is True
|
||||
|
||||
|
||||
def test_accept_follow_serializer_validates_on_context(factories):
|
||||
follow = factories["federation.Follow"](approved=None)
|
||||
impostor = factories["federation.Actor"]()
|
||||
data = {
|
||||
"@context": jsonld.get_default_context_fw(),
|
||||
"id": follow.get_federation_id() + "/accept",
|
||||
"type": "Accept",
|
||||
"actor": impostor.url,
|
||||
"object": serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.AcceptFollowSerializer(
|
||||
data=data, context={"follow_actor": impostor, "follow_target": impostor}
|
||||
)
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert "actor" in serializer.errors["object"]
|
||||
assert "object" in serializer.errors["object"]
|
||||
|
||||
|
||||
def test_undo_follow_serializer_representation(factories):
|
||||
follow = factories["federation.Follow"](approved=True)
|
||||
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"id": follow.get_federation_id() + "/undo",
|
||||
"type": "Undo",
|
||||
"actor": follow.actor.fid,
|
||||
"object": serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.UndoFollowSerializer(follow)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_undo_follow_serializer_save(factories):
|
||||
follow = factories["federation.Follow"](approved=True)
|
||||
|
||||
data = {
|
||||
"@context": jsonld.get_default_context_fw(),
|
||||
"id": follow.get_federation_id() + "/undo",
|
||||
"type": "Undo",
|
||||
"actor": follow.actor.fid,
|
||||
"object": serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.UndoFollowSerializer(data=data)
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
with pytest.raises(models.Follow.DoesNotExist):
|
||||
follow.refresh_from_db()
|
||||
|
||||
|
||||
def test_undo_follow_serializer_validates_on_context(factories):
|
||||
follow = factories["federation.Follow"](approved=True)
|
||||
impostor = factories["federation.Actor"]()
|
||||
data = {
|
||||
"@context": jsonld.get_default_context_fw(),
|
||||
"id": follow.get_federation_id() + "/undo",
|
||||
"type": "Undo",
|
||||
"actor": impostor.url,
|
||||
"object": serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.UndoFollowSerializer(
|
||||
data=data, context={"follow_actor": impostor, "follow_target": impostor}
|
||||
)
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert "actor" in serializer.errors["object"]
|
||||
assert "object" in serializer.errors["object"]
|
||||
|
||||
|
||||
def test_paginated_collection_serializer(factories):
|
||||
uploads = factories["music.Upload"].create_batch(size=5)
|
||||
actor = factories["federation.Actor"](local=True)
|
||||
|
||||
conf = {
|
||||
"id": "https://test.federation/test",
|
||||
"items": uploads,
|
||||
"item_serializer": serializers.UploadSerializer,
|
||||
"actor": actor,
|
||||
"page_size": 2,
|
||||
}
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"type": "Collection",
|
||||
"id": conf["id"],
|
||||
"actor": actor.fid,
|
||||
"totalItems": len(uploads),
|
||||
"current": conf["id"] + "?page=1",
|
||||
"last": conf["id"] + "?page=3",
|
||||
"first": conf["id"] + "?page=1",
|
||||
}
|
||||
|
||||
serializer = serializers.PaginatedCollectionSerializer(conf)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_paginated_collection_serializer_validation():
|
||||
data = {
|
||||
"@context": jsonld.get_default_context_fw(),
|
||||
"type": "Collection",
|
||||
"id": "https://test.federation/test",
|
||||
"totalItems": 5,
|
||||
"actor": "http://test.actor",
|
||||
"first": "https://test.federation/test?page=1",
|
||||
"last": "https://test.federation/test?page=1",
|
||||
"items": [],
|
||||
}
|
||||
|
||||
serializer = serializers.PaginatedCollectionSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
assert serializer.validated_data["totalItems"] == 5
|
||||
assert serializer.validated_data["id"] == data["id"]
|
||||
assert serializer.validated_data["actor"] == data["actor"]
|
||||
|
||||
|
||||
def test_collection_page_serializer_validation():
|
||||
base = "https://test.federation/test"
|
||||
data = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"type": "CollectionPage",
|
||||
"id": base + "?page=2",
|
||||
"totalItems": 5,
|
||||
"actor": "https://test.actor",
|
||||
"items": [],
|
||||
"first": "https://test.federation/test?page=1",
|
||||
"last": "https://test.federation/test?page=3",
|
||||
"prev": base + "?page=1",
|
||||
"next": base + "?page=3",
|
||||
"partOf": base,
|
||||
}
|
||||
|
||||
serializer = serializers.CollectionPageSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
assert serializer.validated_data["totalItems"] == 5
|
||||
assert serializer.validated_data["id"] == data["id"]
|
||||
assert serializer.validated_data["actor"] == data["actor"]
|
||||
assert serializer.validated_data["items"] == []
|
||||
assert serializer.validated_data["prev"] == data["prev"]
|
||||
assert serializer.validated_data["next"] == data["next"]
|
||||
assert serializer.validated_data["partOf"] == data["partOf"]
|
||||
|
||||
|
||||
def test_collection_page_serializer_can_validate_child():
|
||||
data = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"type": "CollectionPage",
|
||||
"id": "https://test.page?page=2",
|
||||
"actor": "https://test.actor",
|
||||
"first": "https://test.page?page=1",
|
||||
"last": "https://test.page?page=3",
|
||||
"partOf": "https://test.page",
|
||||
"totalItems": 1,
|
||||
"items": [{"in": "valid"}],
|
||||
}
|
||||
|
||||
serializer = serializers.CollectionPageSerializer(
|
||||
data=data, context={"item_serializer": serializers.UploadSerializer}
|
||||
)
|
||||
|
||||
# child are validated but not included in data if not valid
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
assert len(serializer.validated_data["items"]) == 0
|
||||
|
||||
|
||||
def test_collection_page_serializer(factories):
|
||||
uploads = factories["music.Upload"].create_batch(size=5)
|
||||
actor = factories["federation.Actor"](local=True)
|
||||
|
||||
conf = {
|
||||
"id": "https://test.federation/test",
|
||||
"item_serializer": serializers.UploadSerializer,
|
||||
"actor": actor,
|
||||
"page": Paginator(uploads, 2).page(2),
|
||||
}
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"type": "CollectionPage",
|
||||
"id": conf["id"] + "?page=2",
|
||||
"actor": actor.fid,
|
||||
"totalItems": len(uploads),
|
||||
"partOf": conf["id"],
|
||||
"prev": conf["id"] + "?page=1",
|
||||
"next": conf["id"] + "?page=3",
|
||||
"first": conf["id"] + "?page=1",
|
||||
"last": conf["id"] + "?page=3",
|
||||
"items": [
|
||||
conf["item_serializer"](
|
||||
i, context={"actor": actor, "include_ap_context": False}
|
||||
).data
|
||||
for i in conf["page"].object_list
|
||||
],
|
||||
}
|
||||
|
||||
serializer = serializers.CollectionPageSerializer(conf)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_music_library_serializer_to_ap(factories):
|
||||
library = factories["music.Library"](privacy_level="everyone")
|
||||
# pending, errored and skippednot included
|
||||
factories["music.Upload"](import_status="pending")
|
||||
factories["music.Upload"](import_status="errored")
|
||||
factories["music.Upload"](import_status="finished")
|
||||
serializer = serializers.LibrarySerializer(library)
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"audience": "https://www.w3.org/ns/activitystreams#Public",
|
||||
"type": "Library",
|
||||
"id": library.fid,
|
||||
"name": library.name,
|
||||
"summary": library.description,
|
||||
"actor": library.actor.fid,
|
||||
"totalItems": 0,
|
||||
"current": library.fid + "?page=1",
|
||||
"last": library.fid + "?page=1",
|
||||
"first": library.fid + "?page=1",
|
||||
"followers": library.followers_url,
|
||||
}
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_music_library_serializer_from_public(factories, mocker):
|
||||
actor = factories["federation.Actor"]()
|
||||
retrieve = mocker.patch(
|
||||
"funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor
|
||||
)
|
||||
data = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"audience": "https://www.w3.org/ns/activitystreams#Public",
|
||||
"name": "Hello",
|
||||
"summary": "World",
|
||||
"type": "Library",
|
||||
"id": "https://library.id",
|
||||
"followers": "https://library.id/followers",
|
||||
"actor": actor.fid,
|
||||
"totalItems": 12,
|
||||
"first": "https://library.id?page=1",
|
||||
"last": "https://library.id?page=2",
|
||||
}
|
||||
serializer = serializers.LibrarySerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
library = serializer.save()
|
||||
|
||||
assert library.actor == actor
|
||||
assert library.fid == data["id"]
|
||||
assert library.uploads_count == data["totalItems"]
|
||||
assert library.privacy_level == "everyone"
|
||||
assert library.name == "Hello"
|
||||
assert library.description == "World"
|
||||
assert library.followers_url == data["followers"]
|
||||
|
||||
retrieve.assert_called_once_with(
|
||||
actor.fid,
|
||||
actor=None,
|
||||
queryset=actor.__class__,
|
||||
serializer_class=serializers.ActorSerializer,
|
||||
)
|
||||
|
||||
|
||||
def test_music_library_serializer_from_private(factories, mocker):
|
||||
actor = factories["federation.Actor"]()
|
||||
retrieve = mocker.patch(
|
||||
"funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor
|
||||
)
|
||||
data = {
|
||||
"@context": jsonld.get_default_context_fw(),
|
||||
"audience": "",
|
||||
"name": "Hello",
|
||||
"summary": "World",
|
||||
"type": "Library",
|
||||
"id": "https://library.id",
|
||||
"followers": "https://library.id/followers",
|
||||
"actor": actor.fid,
|
||||
"totalItems": 12,
|
||||
"first": "https://library.id?page=1",
|
||||
"last": "https://library.id?page=2",
|
||||
}
|
||||
serializer = serializers.LibrarySerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
library = serializer.save()
|
||||
|
||||
assert library.actor == actor
|
||||
assert library.fid == data["id"]
|
||||
assert library.uploads_count == data["totalItems"]
|
||||
assert library.privacy_level == "me"
|
||||
assert library.name == "Hello"
|
||||
assert library.description == "World"
|
||||
assert library.followers_url == data["followers"]
|
||||
retrieve.assert_called_once_with(
|
||||
actor.fid,
|
||||
actor=None,
|
||||
queryset=actor.__class__,
|
||||
serializer_class=serializers.ActorSerializer,
|
||||
)
|
||||
|
||||
|
||||
def test_activity_pub_artist_serializer_to_ap(factories):
|
||||
artist = factories["music.Artist"](attributed=True)
|
||||
expected = {
|
||||
"@context": serializers.AP_CONTEXT,
|
||||
"type": "Artist",
|
||||
"id": artist.fid,
|
||||
"name": artist.name,
|
||||
"musicbrainzId": artist.mbid,
|
||||
"published": artist.creation_date.isoformat(),
|
||||
"attributedTo": artist.attributed_to.fid,
|
||||
}
|
||||
serializer = serializers.ArtistSerializer(artist)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_activity_pub_album_serializer_to_ap(factories):
|
||||
album = factories["music.Album"](attributed=True)
|
||||
|
||||
expected = {
|
||||
"@context": serializers.AP_CONTEXT,
|
||||
"type": "Album",
|
||||
"id": album.fid,
|
||||
"name": album.title,
|
||||
"cover": {
|
||||
"type": "Link",
|
||||
"mediaType": "image/jpeg",
|
||||
"href": utils.full_url(album.cover.url),
|
||||
},
|
||||
"musicbrainzId": album.mbid,
|
||||
"published": album.creation_date.isoformat(),
|
||||
"released": album.release_date.isoformat(),
|
||||
"artists": [
|
||||
serializers.ArtistSerializer(
|
||||
album.artist, context={"include_ap_context": False}
|
||||
).data
|
||||
],
|
||||
"attributedTo": album.attributed_to.fid,
|
||||
}
|
||||
serializer = serializers.AlbumSerializer(album)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_activity_pub_track_serializer_to_ap(factories):
|
||||
track = factories["music.Track"](
|
||||
license="cc-by-4.0", copyright="test", disc_number=3, attributed=True
|
||||
)
|
||||
expected = {
|
||||
"@context": serializers.AP_CONTEXT,
|
||||
"published": track.creation_date.isoformat(),
|
||||
"type": "Track",
|
||||
"musicbrainzId": track.mbid,
|
||||
"id": track.fid,
|
||||
"name": track.title,
|
||||
"position": track.position,
|
||||
"disc": track.disc_number,
|
||||
"license": track.license.conf["identifiers"][0],
|
||||
"copyright": "test",
|
||||
"artists": [
|
||||
serializers.ArtistSerializer(
|
||||
track.artist, context={"include_ap_context": False}
|
||||
).data
|
||||
],
|
||||
"album": serializers.AlbumSerializer(
|
||||
track.album, context={"include_ap_context": False}
|
||||
).data,
|
||||
"attributedTo": track.attributed_to.fid,
|
||||
}
|
||||
serializer = serializers.TrackSerializer(track)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_activity_pub_track_serializer_from_ap(factories, r_mock, mocker):
|
||||
track_attributed_to = factories["federation.Actor"]()
|
||||
album_attributed_to = factories["federation.Actor"]()
|
||||
album_artist_attributed_to = factories["federation.Actor"]()
|
||||
artist_attributed_to = factories["federation.Actor"]()
|
||||
|
||||
activity = factories["federation.Activity"]()
|
||||
published = timezone.now()
|
||||
released = timezone.now().date()
|
||||
data = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"type": "Track",
|
||||
"id": "http://hello.track",
|
||||
"published": published.isoformat(),
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"name": "Black in back",
|
||||
"position": 5,
|
||||
"disc": 1,
|
||||
"attributedTo": track_attributed_to.fid,
|
||||
"album": {
|
||||
"type": "Album",
|
||||
"id": "http://hello.album",
|
||||
"name": "Purple album",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"published": published.isoformat(),
|
||||
"released": released.isoformat(),
|
||||
"attributedTo": album_attributed_to.fid,
|
||||
"cover": {
|
||||
"type": "Link",
|
||||
"href": "https://cover.image/test.png",
|
||||
"mediaType": "image/png",
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"type": "Artist",
|
||||
"id": "http://hello.artist",
|
||||
"name": "John Smith",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"published": published.isoformat(),
|
||||
"attributedTo": album_artist_attributed_to.fid,
|
||||
}
|
||||
],
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"type": "Artist",
|
||||
"id": "http://hello.trackartist",
|
||||
"name": "Bob Smith",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"attributedTo": artist_attributed_to.fid,
|
||||
"published": published.isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
r_mock.get(data["album"]["cover"]["href"], body=io.BytesIO(b"coucou"))
|
||||
serializer = serializers.TrackSerializer(data=data, context={"activity": activity})
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
track = serializer.save()
|
||||
album = track.album
|
||||
artist = track.artist
|
||||
album_artist = track.album.artist
|
||||
|
||||
assert track.from_activity == activity
|
||||
assert track.fid == data["id"]
|
||||
assert track.title == data["name"]
|
||||
assert track.position == data["position"]
|
||||
assert track.disc_number == data["disc"]
|
||||
assert track.creation_date == published
|
||||
assert track.attributed_to == track_attributed_to
|
||||
assert str(track.mbid) == data["musicbrainzId"]
|
||||
|
||||
assert album.from_activity == activity
|
||||
assert album.cover.read() == b"coucou"
|
||||
assert album.cover.path.endswith(".png")
|
||||
assert album.title == data["album"]["name"]
|
||||
assert album.fid == data["album"]["id"]
|
||||
assert str(album.mbid) == data["album"]["musicbrainzId"]
|
||||
assert album.creation_date == published
|
||||
assert album.release_date == released
|
||||
assert album.attributed_to == album_attributed_to
|
||||
|
||||
assert artist.from_activity == activity
|
||||
assert artist.name == data["artists"][0]["name"]
|
||||
assert artist.fid == data["artists"][0]["id"]
|
||||
assert str(artist.mbid) == data["artists"][0]["musicbrainzId"]
|
||||
assert artist.creation_date == published
|
||||
assert artist.attributed_to == artist_attributed_to
|
||||
|
||||
assert album_artist.from_activity == activity
|
||||
assert album_artist.name == data["album"]["artists"][0]["name"]
|
||||
assert album_artist.fid == data["album"]["artists"][0]["id"]
|
||||
assert str(album_artist.mbid) == data["album"]["artists"][0]["musicbrainzId"]
|
||||
assert album_artist.creation_date == published
|
||||
assert album_artist.attributed_to == album_artist_attributed_to
|
||||
|
||||
|
||||
def test_activity_pub_upload_serializer_from_ap(factories, mocker, r_mock):
|
||||
activity = factories["federation.Activity"]()
|
||||
library = factories["music.Library"]()
|
||||
|
||||
published = timezone.now()
|
||||
updated = timezone.now()
|
||||
released = timezone.now().date()
|
||||
data = {
|
||||
"@context": serializers.AP_CONTEXT,
|
||||
"type": "Audio",
|
||||
"id": "https://track.file",
|
||||
"name": "Ignored",
|
||||
"published": published.isoformat(),
|
||||
"updated": updated.isoformat(),
|
||||
"duration": 43,
|
||||
"bitrate": 42,
|
||||
"size": 66,
|
||||
"url": {"href": "https://audio.file", "type": "Link", "mediaType": "audio/mp3"},
|
||||
"library": library.fid,
|
||||
"track": {
|
||||
"type": "Track",
|
||||
"id": "http://hello.track",
|
||||
"published": published.isoformat(),
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"name": "Black in back",
|
||||
"position": 5,
|
||||
"album": {
|
||||
"type": "Album",
|
||||
"id": "http://hello.album",
|
||||
"name": "Purple album",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"published": published.isoformat(),
|
||||
"released": released.isoformat(),
|
||||
"cover": {
|
||||
"type": "Link",
|
||||
"href": "https://cover.image/test.png",
|
||||
"mediaType": "image/png",
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"type": "Artist",
|
||||
"id": "http://hello.artist",
|
||||
"name": "John Smith",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"published": published.isoformat(),
|
||||
}
|
||||
],
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"type": "Artist",
|
||||
"id": "http://hello.trackartist",
|
||||
"name": "Bob Smith",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"published": published.isoformat(),
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
r_mock.get(data["track"]["album"]["cover"]["href"], body=io.BytesIO(b"coucou"))
|
||||
|
||||
serializer = serializers.UploadSerializer(data=data, context={"activity": activity})
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
track_create = mocker.spy(serializers.TrackSerializer, "create")
|
||||
upload = serializer.save()
|
||||
|
||||
assert upload.track.from_activity == activity
|
||||
assert upload.from_activity == activity
|
||||
assert track_create.call_count == 1
|
||||
assert upload.fid == data["id"]
|
||||
assert upload.track.fid == data["track"]["id"]
|
||||
assert upload.duration == data["duration"]
|
||||
assert upload.size == data["size"]
|
||||
assert upload.bitrate == data["bitrate"]
|
||||
assert upload.source == data["url"]["href"]
|
||||
assert upload.mimetype == data["url"]["mediaType"]
|
||||
assert upload.creation_date == published
|
||||
assert upload.import_status == "finished"
|
||||
assert upload.modification_date == updated
|
||||
|
||||
|
||||
def test_activity_pub_upload_serializer_validtes_library_actor(factories, mocker):
|
||||
library = factories["music.Library"]()
|
||||
usurpator = factories["federation.Actor"]()
|
||||
|
||||
serializer = serializers.UploadSerializer(data={}, context={"actor": usurpator})
|
||||
|
||||
with pytest.raises(serializers.serializers.ValidationError):
|
||||
serializer.validate_library(library.fid)
|
||||
|
||||
|
||||
def test_activity_pub_audio_serializer_to_ap(factories):
|
||||
upload = factories["music.Upload"](
|
||||
mimetype="audio/mp3", bitrate=42, duration=43, size=44
|
||||
)
|
||||
expected = {
|
||||
"@context": serializers.AP_CONTEXT,
|
||||
"type": "Audio",
|
||||
"id": upload.fid,
|
||||
"name": upload.track.full_name,
|
||||
"published": upload.creation_date.isoformat(),
|
||||
"updated": upload.modification_date.isoformat(),
|
||||
"duration": upload.duration,
|
||||
"bitrate": upload.bitrate,
|
||||
"size": upload.size,
|
||||
"url": {
|
||||
"href": utils.full_url(upload.listen_url),
|
||||
"type": "Link",
|
||||
"mediaType": "audio/mp3",
|
||||
},
|
||||
"library": upload.library.fid,
|
||||
"track": serializers.TrackSerializer(
|
||||
upload.track, context={"include_ap_context": False}
|
||||
).data,
|
||||
}
|
||||
|
||||
serializer = serializers.UploadSerializer(upload)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_local_actor_serializer_to_ap(factories):
|
||||
expected = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"id": "https://test.federation/user",
|
||||
"type": "Person",
|
||||
"following": "https://test.federation/user/following",
|
||||
"followers": "https://test.federation/user/followers",
|
||||
"inbox": "https://test.federation/user/inbox",
|
||||
"outbox": "https://test.federation/user/outbox",
|
||||
"preferredUsername": "user",
|
||||
"name": "Real User",
|
||||
"summary": "Hello world",
|
||||
"manuallyApprovesFollowers": False,
|
||||
"publicKey": {
|
||||
"id": "https://test.federation/user#main-key",
|
||||
"owner": "https://test.federation/user",
|
||||
"publicKeyPem": "yolo",
|
||||
},
|
||||
"endpoints": {"sharedInbox": "https://test.federation/inbox"},
|
||||
}
|
||||
ac = models.Actor.objects.create(
|
||||
fid=expected["id"],
|
||||
inbox_url=expected["inbox"],
|
||||
outbox_url=expected["outbox"],
|
||||
shared_inbox_url=expected["endpoints"]["sharedInbox"],
|
||||
followers_url=expected["followers"],
|
||||
following_url=expected["following"],
|
||||
public_key=expected["publicKey"]["publicKeyPem"],
|
||||
preferred_username=expected["preferredUsername"],
|
||||
name=expected["name"],
|
||||
domain=models.Domain.objects.create(pk="test.federation"),
|
||||
summary=expected["summary"],
|
||||
type="Person",
|
||||
manually_approves_followers=False,
|
||||
)
|
||||
user = factories["users.User"]()
|
||||
user.actor = ac
|
||||
user.save()
|
||||
ac.refresh_from_db()
|
||||
expected["icon"] = {
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
"url": utils.full_url(user.avatar.crop["400x400"].url),
|
||||
}
|
||||
serializer = serializers.ActorSerializer(ac)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_activity_serializer_validate_recipients_empty(db):
|
||||
s = serializers.BaseActivitySerializer()
|
||||
|
||||
with pytest.raises(serializers.serializers.ValidationError):
|
||||
s.validate_recipients({})
|
||||
|
||||
with pytest.raises(serializers.serializers.ValidationError):
|
||||
s.validate_recipients({"to": []})
|
||||
|
||||
with pytest.raises(serializers.serializers.ValidationError):
|
||||
s.validate_recipients({"cc": []})
|
||||
|
||||
|
||||
def test_track_serializer_update_license(factories):
|
||||
licenses.load(licenses.LICENSES)
|
||||
|
||||
obj = factories["music.Track"](license=None)
|
||||
|
||||
serializer = serializers.TrackSerializer()
|
||||
serializer.update(obj, {"license": "http://creativecommons.org/licenses/by/2.0/"})
|
||||
|
||||
obj.refresh_from_db()
|
||||
|
||||
assert obj.license_id == "cc-by-2.0"
|
||||
|
|
|
@ -533,3 +533,18 @@ def test_queryset_local_entities(factories, settings, factory):
|
|||
factories[factory](fid="https://noope/3")
|
||||
|
||||
assert list(obj1.__class__.objects.local().order_by("id")) == [obj1, obj2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"federation_hostname, fid, expected",
|
||||
[
|
||||
("test.domain", "http://test.domain/", True),
|
||||
("test.domain", None, True),
|
||||
("test.domain", "https://test.domain/", True),
|
||||
("test.otherdomain", "http://test.domain/", False),
|
||||
],
|
||||
)
|
||||
def test_api_model_mixin_is_local(federation_hostname, fid, expected, settings):
|
||||
settings.FEDERATION_HOSTNAME = federation_hostname
|
||||
obj = models.Track(fid=fid)
|
||||
assert obj.is_local is expected
|
||||
|
|
|
@ -56,3 +56,16 @@ def test_track_position_mutation(factories):
|
|||
track.refresh_from_db()
|
||||
|
||||
assert track.position == 12
|
||||
|
||||
|
||||
def test_track_mutation_apply_outbox(factories, mocker):
|
||||
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
|
||||
track = factories["music.Track"](position=4)
|
||||
mutation = factories["common.Mutation"](
|
||||
type="update", target=track, payload={"position": 12}
|
||||
)
|
||||
mutation.apply()
|
||||
|
||||
dispatch.assert_called_once_with(
|
||||
{"type": "Update", "object": {"type": "Track"}}, context={"track": track}
|
||||
)
|
||||
|
|
|
@ -34,6 +34,7 @@ def test_artist_album_serializer(factories, to_api_date):
|
|||
album = album.__class__.objects.with_tracks_count().get(pk=album.pk)
|
||||
expected = {
|
||||
"id": album.id,
|
||||
"fid": album.fid,
|
||||
"mbid": str(album.mbid),
|
||||
"title": album.title,
|
||||
"artist": album.artist.id,
|
||||
|
@ -47,6 +48,7 @@ def test_artist_album_serializer(factories, to_api_date):
|
|||
"small_square_crop": album.cover.crop["50x50"].url,
|
||||
},
|
||||
"release_date": to_api_date(album.release_date),
|
||||
"is_local": album.is_local,
|
||||
}
|
||||
serializer = serializers.ArtistAlbumSerializer(album)
|
||||
|
||||
|
@ -61,8 +63,10 @@ def test_artist_with_albums_serializer(factories, to_api_date):
|
|||
|
||||
expected = {
|
||||
"id": artist.id,
|
||||
"fid": artist.fid,
|
||||
"mbid": str(artist.mbid),
|
||||
"name": artist.name,
|
||||
"is_local": artist.is_local,
|
||||
"creation_date": to_api_date(artist.creation_date),
|
||||
"albums": [serializers.ArtistAlbumSerializer(album).data],
|
||||
}
|
||||
|
@ -79,6 +83,7 @@ def test_album_track_serializer(factories, to_api_date):
|
|||
|
||||
expected = {
|
||||
"id": track.id,
|
||||
"fid": track.fid,
|
||||
"artist": serializers.ArtistSimpleSerializer(track.artist).data,
|
||||
"album": track.album.id,
|
||||
"mbid": str(track.mbid),
|
||||
|
@ -91,6 +96,7 @@ def test_album_track_serializer(factories, to_api_date):
|
|||
"duration": None,
|
||||
"license": track.license.code,
|
||||
"copyright": track.copyright,
|
||||
"is_local": track.is_local,
|
||||
}
|
||||
serializer = serializers.AlbumTrackSerializer(track)
|
||||
assert serializer.data == expected
|
||||
|
@ -154,6 +160,7 @@ def test_album_serializer(factories, to_api_date):
|
|||
album = track1.album
|
||||
expected = {
|
||||
"id": album.id,
|
||||
"fid": album.fid,
|
||||
"mbid": str(album.mbid),
|
||||
"title": album.title,
|
||||
"artist": serializers.ArtistSimpleSerializer(album.artist).data,
|
||||
|
@ -167,6 +174,7 @@ def test_album_serializer(factories, to_api_date):
|
|||
},
|
||||
"release_date": to_api_date(album.release_date),
|
||||
"tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data,
|
||||
"is_local": album.is_local,
|
||||
}
|
||||
serializer = serializers.AlbumSerializer(album)
|
||||
|
||||
|
@ -181,6 +189,7 @@ def test_track_serializer(factories, to_api_date):
|
|||
setattr(track, "playable_uploads", [upload])
|
||||
expected = {
|
||||
"id": track.id,
|
||||
"fid": track.fid,
|
||||
"artist": serializers.ArtistSimpleSerializer(track.artist).data,
|
||||
"album": serializers.TrackAlbumSerializer(track.album).data,
|
||||
"mbid": str(track.mbid),
|
||||
|
@ -193,6 +202,7 @@ def test_track_serializer(factories, to_api_date):
|
|||
"listen_url": track.listen_url,
|
||||
"license": upload.track.license.code,
|
||||
"copyright": upload.track.copyright,
|
||||
"is_local": upload.track.is_local,
|
||||
}
|
||||
serializer = serializers.TrackSerializer(track)
|
||||
assert serializer.data == expected
|
||||
|
|
|
@ -42,9 +42,38 @@ def test_can_create_track_from_file_metadata_no_mbid(db, mocker):
|
|||
assert track.album.release_date == datetime.date(2012, 8, 15)
|
||||
assert track.artist.name == metadata["artists"][0]["name"]
|
||||
assert track.artist.mbid is None
|
||||
assert track.artist.attributed_to is None
|
||||
match_license.assert_called_once_with(metadata["license"], metadata["copyright"])
|
||||
|
||||
|
||||
def test_can_create_track_from_file_metadata_attributed_to(factories, mocker):
|
||||
actor = factories["federation.Actor"]()
|
||||
metadata = {
|
||||
"title": "Test track",
|
||||
"artists": [{"name": "Test artist"}],
|
||||
"album": {"title": "Test album", "release_date": datetime.date(2012, 8, 15)},
|
||||
"position": 4,
|
||||
"disc_number": 2,
|
||||
"copyright": "2018 Someone",
|
||||
}
|
||||
|
||||
track = tasks.get_track_from_import_metadata(metadata, attributed_to=actor)
|
||||
|
||||
assert track.title == metadata["title"]
|
||||
assert track.mbid is None
|
||||
assert track.position == 4
|
||||
assert track.disc_number == 2
|
||||
assert track.copyright == metadata["copyright"]
|
||||
assert track.attributed_to == actor
|
||||
assert track.album.title == metadata["album"]["title"]
|
||||
assert track.album.mbid is None
|
||||
assert track.album.release_date == datetime.date(2012, 8, 15)
|
||||
assert track.album.attributed_to == actor
|
||||
assert track.artist.name == metadata["artists"][0]["name"]
|
||||
assert track.artist.mbid is None
|
||||
assert track.artist.attributed_to == actor
|
||||
|
||||
|
||||
def test_can_create_track_from_file_metadata_mbid(factories, mocker):
|
||||
metadata = {
|
||||
"title": "Test track",
|
||||
|
@ -229,6 +258,7 @@ def test_upload_import(now, factories, temp_signal, mocker):
|
|||
outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
|
||||
update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
|
||||
get_picture = mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture")
|
||||
get_track_from_import_metadata = mocker.spy(tasks, "get_track_from_import_metadata")
|
||||
track = factories["music.Track"](album__cover="")
|
||||
upload = factories["music.Upload"](
|
||||
track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}}
|
||||
|
@ -246,6 +276,10 @@ def test_upload_import(now, factories, temp_signal, mocker):
|
|||
update_album_cover.assert_called_once_with(
|
||||
upload.track.album, cover_data=get_picture.return_value, source=upload.source
|
||||
)
|
||||
assert (
|
||||
get_track_from_import_metadata.call_args[-1]["attributed_to"]
|
||||
== upload.library.actor
|
||||
)
|
||||
handler.assert_called_once_with(
|
||||
upload=upload,
|
||||
old_status="pending",
|
||||
|
@ -478,9 +512,15 @@ def test_update_album_cover_file_cover_separate_file(ext, mimetype, factories, m
|
|||
)
|
||||
|
||||
|
||||
def test_federation_audio_track_to_metadata(now):
|
||||
def test_federation_audio_track_to_metadata(now, mocker):
|
||||
published = now
|
||||
released = now.date()
|
||||
references = {
|
||||
"http://track.attributed": mocker.Mock(),
|
||||
"http://album.attributed": mocker.Mock(),
|
||||
"http://album-artist.attributed": mocker.Mock(),
|
||||
"http://artist.attributed": mocker.Mock(),
|
||||
}
|
||||
payload = {
|
||||
"@context": jsonld.get_default_context(),
|
||||
"type": "Track",
|
||||
|
@ -492,6 +532,7 @@ def test_federation_audio_track_to_metadata(now):
|
|||
"published": published.isoformat(),
|
||||
"license": "http://creativecommons.org/licenses/by-sa/4.0/",
|
||||
"copyright": "2018 Someone",
|
||||
"attributedTo": "http://track.attributed",
|
||||
"album": {
|
||||
"published": published.isoformat(),
|
||||
"type": "Album",
|
||||
|
@ -499,6 +540,7 @@ def test_federation_audio_track_to_metadata(now):
|
|||
"name": "Purple album",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"released": released.isoformat(),
|
||||
"attributedTo": "http://album.attributed",
|
||||
"artists": [
|
||||
{
|
||||
"type": "Artist",
|
||||
|
@ -506,6 +548,7 @@ def test_federation_audio_track_to_metadata(now):
|
|||
"id": "http://hello.artist",
|
||||
"name": "John Smith",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"attributedTo": "http://album-artist.attributed",
|
||||
}
|
||||
],
|
||||
"cover": {
|
||||
|
@ -521,6 +564,7 @@ def test_federation_audio_track_to_metadata(now):
|
|||
"id": "http://hello.trackartist",
|
||||
"name": "Bob Smith",
|
||||
"musicbrainzId": str(uuid.uuid4()),
|
||||
"attributedTo": "http://artist.attributed",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
@ -535,8 +579,10 @@ def test_federation_audio_track_to_metadata(now):
|
|||
"mbid": payload["musicbrainzId"],
|
||||
"fdate": serializer.validated_data["published"],
|
||||
"fid": payload["id"],
|
||||
"attributed_to": references["http://track.attributed"],
|
||||
"album": {
|
||||
"title": payload["album"]["name"],
|
||||
"attributed_to": references["http://album.attributed"],
|
||||
"release_date": released,
|
||||
"mbid": payload["album"]["musicbrainzId"],
|
||||
"fid": payload["album"]["id"],
|
||||
|
@ -546,6 +592,7 @@ def test_federation_audio_track_to_metadata(now):
|
|||
"name": a["name"],
|
||||
"mbid": a["musicbrainzId"],
|
||||
"fid": a["id"],
|
||||
"attributed_to": references["http://album-artist.attributed"],
|
||||
"fdate": serializer.validated_data["album"]["artists"][i][
|
||||
"published"
|
||||
],
|
||||
|
@ -561,6 +608,7 @@ def test_federation_audio_track_to_metadata(now):
|
|||
"mbid": a["musicbrainzId"],
|
||||
"fid": a["id"],
|
||||
"fdate": serializer.validated_data["artists"][i]["published"],
|
||||
"attributed_to": references["http://artist.attributed"],
|
||||
}
|
||||
for i, a in enumerate(payload["artists"])
|
||||
],
|
||||
|
@ -570,7 +618,9 @@ def test_federation_audio_track_to_metadata(now):
|
|||
},
|
||||
}
|
||||
|
||||
result = tasks.federation_audio_track_to_metadata(serializer.validated_data)
|
||||
result = tasks.federation_audio_track_to_metadata(
|
||||
serializer.validated_data, references
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
|
||||
|
@ -747,3 +797,14 @@ def test_get_prunable_artists(factories):
|
|||
factories["music.Track"](album__artist=non_prunable_album_artist)
|
||||
|
||||
assert list(tasks.get_prunable_artists()) == [prunable_artist]
|
||||
|
||||
|
||||
def test_update_library_entity(factories, mocker):
|
||||
artist = factories["music.Artist"]()
|
||||
save = mocker.spy(artist, "save")
|
||||
|
||||
tasks.update_library_entity(artist, {"name": "Hello"})
|
||||
save.assert_called_once_with(update_fields=["name"])
|
||||
|
||||
artist.refresh_from_db()
|
||||
assert artist.name == "Hello"
|
||||
|
|
|
@ -88,8 +88,7 @@ to posting an activity to an outbox, we create an object, with the proper payloa
|
|||
Receiving an activity from a remote actor in a local inbox is basically the same, but we skip step 2.
|
||||
|
||||
Funkwhale does not support all activities, and we have a basic routing logic to handle
|
||||
specific activities, and discard unsupported ones. Unsupported activities are still
|
||||
received and stored though.
|
||||
specific activities, and discard unsupported ones.
|
||||
|
||||
If a delivered activity matches one of our routes, a dedicated handler is called,
|
||||
which can trigger additional logic. For instance, if we receive a :ref:`activity-create` activity
|
||||
|
@ -102,6 +101,24 @@ Links:
|
|||
- `Delivery logic for activities <https://dev.funkwhale.audio/funkwhale/funkwhale/blob/develop/api/funkwhale_api/federation/tasks.py>`_
|
||||
|
||||
|
||||
.. _service-actor:
|
||||
|
||||
Service actor
|
||||
-------------
|
||||
|
||||
In some situations, we will send messages or authenticate our fetches using what we call
|
||||
the service actor. A service actor is an ActivityPub actor object that acts on behalf
|
||||
of a Funkwhale server.
|
||||
|
||||
The actor id usually looks like ``https://yourdomain.com/federation/actors/service``, but
|
||||
the reliable way to determine it is to query the nodeinfo endpoint and use the value
|
||||
available in the ``metadata > actorId`` field.
|
||||
|
||||
Funkwhale generally considers that the service actor has authority to send activities
|
||||
associated with any object on the same domain. For instance, the service actor
|
||||
could send a :ref:`activity-delete` activity linked to another users' library on the same domain.
|
||||
|
||||
|
||||
Supported activities
|
||||
--------------------
|
||||
|
||||
|
@ -305,6 +322,59 @@ the audio library's actor are the same.
|
|||
|
||||
If no local actor follows the audio's library, the activity will be discarded.
|
||||
|
||||
.. _activity-update:
|
||||
|
||||
|
||||
Update
|
||||
^^^^^^
|
||||
|
||||
Supported on
|
||||
************
|
||||
|
||||
- :ref:`object-library` objects
|
||||
- :ref:`object-track` objects
|
||||
|
||||
Example
|
||||
*******
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{}
|
||||
],
|
||||
"to": [
|
||||
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
|
||||
],
|
||||
"type": "Update",
|
||||
"actor": "https://awesome.music/federation/actors/Bob",
|
||||
"object": {}
|
||||
}
|
||||
|
||||
.. note::
|
||||
|
||||
Refer to :ref:`object-library` or :ref:`object-track` to see the structure of the ``object`` attribute.
|
||||
|
||||
Internal logic
|
||||
**************
|
||||
|
||||
When a :ref:`activity-update` is received with a :ref:`object-library` or :ref:`object-track` object,
|
||||
Funkwhale will try to update the local copy of the corresponding object in it's database.
|
||||
|
||||
|
||||
Checks
|
||||
******
|
||||
|
||||
Checks vary depending of the type of object associated with the update.
|
||||
|
||||
For :ref:`object-library` objects, we ensure the actor sending the message is the owner of the library.
|
||||
|
||||
For musical entities such as :ref:`object-track`, we ensure the actor sending the message
|
||||
matches the :ref:`property-attributedTo` property declared on the local copy on the object,
|
||||
or the :ref:`service-actor`.
|
||||
|
||||
.. _activity-delete:
|
||||
|
||||
Delete
|
||||
|
@ -613,3 +683,19 @@ For :ref:`object-audio` url objects:
|
|||
|
||||
- If the audio's library is public, audio file can be accessed without restriction
|
||||
- Otherwise, the HTTP request must be signed by an actor with an approved follow on the audio's library
|
||||
|
||||
|
||||
Properties
|
||||
----------
|
||||
|
||||
.. _property-attributedTo:
|
||||
|
||||
attributedTo
|
||||
------------
|
||||
|
||||
Funkwhale will generally use the ``attributedTo`` property to communicate
|
||||
who is responsible for a given object. When an object has the ``attributedTo`` attribute,
|
||||
the associated actor has the permission to :ref:`activity-update`, :ref:`activity-delete` or
|
||||
more generally apply any kind of activity on the object.
|
||||
|
||||
In addition, Funkwhale consider all the objects of a domain as attributed to its corresponding :ref:`service-actor`.
|
||||
|
|
|
@ -662,12 +662,20 @@ definitions:
|
|||
type: "integer"
|
||||
format: "int64"
|
||||
example: 42
|
||||
fid:
|
||||
type: string
|
||||
format: uri
|
||||
description: "The artist Federation ID (unique accross federation)"
|
||||
name:
|
||||
type: "string"
|
||||
example: "System of a Down"
|
||||
creation_date:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
is_local:
|
||||
type: "boolean"
|
||||
description: "Indicates if the object was initally created locally or on another server"
|
||||
|
||||
Artist:
|
||||
type: "object"
|
||||
allOf:
|
||||
|
@ -689,6 +697,10 @@ definitions:
|
|||
type: "integer"
|
||||
format: "int64"
|
||||
example: 16
|
||||
fid:
|
||||
type: string
|
||||
format: uri
|
||||
description: "The album Federation ID (unique accross federation)"
|
||||
artist:
|
||||
type: "integer"
|
||||
format: "int64"
|
||||
|
@ -708,6 +720,9 @@ definitions:
|
|||
type: "boolean"
|
||||
cover:
|
||||
$ref: "#/definitions/Image"
|
||||
is_local:
|
||||
type: "boolean"
|
||||
description: "Indicates if the object was initally created locally or on another server"
|
||||
|
||||
Album:
|
||||
type: "object"
|
||||
|
@ -819,6 +834,10 @@ definitions:
|
|||
type: "integer"
|
||||
format: "int64"
|
||||
example: 66
|
||||
fid:
|
||||
type: string
|
||||
format: uri
|
||||
description: "The track Federation ID (unique accross federation)"
|
||||
artist:
|
||||
type: "integer"
|
||||
format: "int64"
|
||||
|
@ -853,6 +872,9 @@ definitions:
|
|||
type: "string"
|
||||
description: "Identifier of the license that is linked to the track"
|
||||
example: "cc-by-nc-nd-4.0"
|
||||
is_local:
|
||||
type: "boolean"
|
||||
description: "Indicates if the object was initally created locally or on another server"
|
||||
|
||||
AlbumTrack:
|
||||
type: "object"
|
||||
|
|
|
@ -65,6 +65,7 @@
|
|||
</modal>
|
||||
</template>
|
||||
<router-link
|
||||
v-if="track.is_local"
|
||||
:to="{name: 'library.tracks.edit', params: {id: track.id }}"
|
||||
class="ui icon labeled button">
|
||||
<i class="edit icon"></i>
|
||||
|
|
|
@ -63,6 +63,16 @@
|
|||
<translate translate-context="*/*/*">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate translate-context="Content/*/*/Noun">Federation ID</translate>
|
||||
</td>
|
||||
<td :title="track.fid">
|
||||
<a :href="track.fid" target="_blank" rel="noopener noreferrer">
|
||||
{{ track.fid|truncate(65)}}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
|
@ -6,8 +6,11 @@
|
|||
<translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this track</translate>
|
||||
<translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this track</translate>
|
||||
</h2>
|
||||
<div class="ui message" v-if="!object.is_local">
|
||||
<translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate>
|
||||
</div>
|
||||
<edit-form
|
||||
v-if="!isLoadingLicenses"
|
||||
v-else-if="!isLoadingLicenses"
|
||||
:object-type="objectType"
|
||||
:object="object"
|
||||
:can-edit="canEdit"
|
||||
|
|
Loading…
Reference in New Issue