Merge branch 'attribute-artist' into 'develop'

Attribute artist

See merge request funkwhale/funkwhale!713
This commit is contained in:
Eliot Berriot 2019-04-11 10:17:10 +02:00
commit b45cada689
31 changed files with 1741 additions and 46 deletions

View File

@ -2,7 +2,7 @@ import persisting_theory
from rest_framework import serializers from rest_framework import serializers
from django.db import models from django.db import models, transaction
class ConfNotFound(KeyError): class ConfNotFound(KeyError):
@ -23,6 +23,7 @@ class Registry(persisting_theory.Registry):
return decorator return decorator
@transaction.atomic
def apply(self, type, obj, payload): def apply(self, type, obj, payload):
conf = self.get_conf(type, obj) conf = self.get_conf(type, obj)
serializer = conf["serializer_class"](obj, data=payload) serializer = conf["serializer_class"](obj, data=payload)
@ -73,6 +74,9 @@ class MutationSerializer(serializers.Serializer):
def apply(self, obj, validated_data): def apply(self, obj, validated_data):
raise NotImplementedError() raise NotImplementedError()
def post_apply(self, obj, validated_data):
pass
def get_previous_state(self, obj, validated_data): def get_previous_state(self, obj, validated_data):
return return
@ -88,8 +92,11 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer):
kwargs.setdefault("partial", True) kwargs.setdefault("partial", True)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@transaction.atomic
def apply(self, obj, validated_data): 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): def validate(self, validated_data):
if not validated_data: if not validated_data:

View File

@ -201,3 +201,30 @@ def concat_dicts(*dicts):
n.update(d) n.update(d)
return n 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

View File

@ -2,6 +2,8 @@ import uuid
import factory import factory
import persisting_theory import persisting_theory
from django.conf import settings
from faker.providers import internet as internet_provider from faker.providers import internet as internet_provider
@ -50,11 +52,11 @@ class FunkwhaleProvider(internet_provider.Provider):
not random enough not random enough
""" """
def federation_url(self, prefix=""): def federation_url(self, prefix="", local=False):
def path_generator(): def path_generator():
return "{}/{}".format(prefix, uuid.uuid4()) return "{}/{}".format(prefix, uuid.uuid4())
domain = self.domain_name() domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
protocol = "https" protocol = "https"
path = path_generator() path = path_generator()
return "{}://{}/{}".format(protocol, domain, path) return "{}://{}/{}".format(protocol, domain, path)

View File

@ -365,27 +365,6 @@ class OutboxRouter(Router):
return activities 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): def match_route(route, payload):
for key, value in route.items(): for key, value in route.items():
payload_value = recursive_getattr(payload, key, permissive=True) 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) remote_inbox_urls.add(actor.shared_inbox_url or actor.inbox_url)
urls.append(r["target"].followers_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] deliveries = [models.Delivery(inbox_url=url) for url in remote_inbox_urls]
inbox_items = [ inbox_items = [
models.InboxItem(actor=actor, type=type) for actor in local_recipients models.InboxItem(actor=actor, type=type) for actor in local_recipients

View File

@ -75,6 +75,15 @@ class DomainFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
model = "federation.Domain" model = "federation.Domain"
django_get_or_create = ("name",) 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 @registry.register
class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class ActorFactory(NoUpdateOnCreate, factory.DjangoModelFactory):

View File

@ -57,7 +57,9 @@ def insert_context(ctx, doc):
existing = doc["@context"] existing = doc["@context"]
if isinstance(existing, list): if isinstance(existing, list):
if ctx not in existing: if ctx not in existing:
existing = existing[:]
existing.append(ctx) existing.append(ctx)
doc["@context"] = existing
else: else:
doc["@context"] = [existing, ctx] doc["@context"] = [existing, ctx]
return doc return doc
@ -215,6 +217,15 @@ def get_default_context():
return ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", {}] 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): class JsonLdSerializer(serializers.Serializer):
def run_validation(self, data=empty): def run_validation(self, data=empty):
if data and data is not empty and self.context.get("expand", True): if data and data is not empty and self.context.get("expand", True):

View File

@ -264,6 +264,25 @@ class Actor(models.Model):
self.private_key = v[0].decode("utf-8") self.private_key = v[0].decode("utf-8")
self.public_key = v[1].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): class InboxItem(models.Model):
""" """

View File

@ -3,6 +3,7 @@ import logging
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from . import activity from . import activity
from . import actors
from . import serializers from . import serializers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -269,3 +270,79 @@ def outbox_delete_audio(context):
serializer.data, to=[{"type": "followers", "target": library}] 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"}],
),
}

View File

@ -7,9 +7,11 @@ from django.core.paginator import Paginator
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import utils as funkwhale_utils 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 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() AP_CONTEXT = jsonld.get_default_context()
@ -670,7 +672,7 @@ class CollectionPageSerializer(jsonld.JsonLdSerializer):
"first": jsonld.first_id(contexts.AS.first), "first": jsonld.first_id(contexts.AS.first),
"last": jsonld.first_id(contexts.AS.last), "last": jsonld.first_id(contexts.AS.last),
"next": jsonld.first_id(contexts.AS.next), "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), "partOf": jsonld.first_id(contexts.AS.partOf),
} }
@ -731,6 +733,7 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
"name": jsonld.first_val(contexts.AS.name), "name": jsonld.first_val(contexts.AS.name),
"published": jsonld.first_val(contexts.AS.published), "published": jsonld.first_val(contexts.AS.published),
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId), "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() published = serializers.DateTimeField()
musicbrainzId = serializers.UUIDField(allow_null=True, required=False) musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
name = serializers.CharField(max_length=1000) 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): class ArtistSerializer(MusicEntitySerializer):
updateable_fields = [
("name", "name"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
]
class Meta: class Meta:
jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING jsonld_mapping = MUSIC_ENTITY_JSONLD_MAPPING
@ -752,6 +775,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
if instance.attributed_to
else None,
} }
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
@ -765,6 +791,12 @@ class AlbumSerializer(MusicEntitySerializer):
cover = LinkSerializer( cover = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False allowed_mimetypes=["image/*"], allow_null=True, required=False
) )
updateable_fields = [
("name", "title"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("released", "release_date"),
]
class Meta: class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts( jsonld_mapping = funkwhale_utils.concat_dicts(
@ -791,6 +823,9 @@ class AlbumSerializer(MusicEntitySerializer):
instance.artist, context={"include_ap_context": False} instance.artist, context={"include_ap_context": False}
).data ).data
], ],
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
} }
if instance.cover: if instance.cover:
d["cover"] = { d["cover"] = {
@ -812,6 +847,16 @@ class TrackSerializer(MusicEntitySerializer):
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)
updateable_fields = [
("name", "title"),
("musicbrainzId", "mbid"),
("attributedTo", "attributed_to"),
("disc", "disc_number"),
("position", "position"),
("copyright", "copyright"),
("license", "license"),
]
class Meta: class Meta:
jsonld_mapping = funkwhale_utils.concat_dicts( jsonld_mapping = funkwhale_utils.concat_dicts(
MUSIC_ENTITY_JSONLD_MAPPING, MUSIC_ENTITY_JSONLD_MAPPING,
@ -846,6 +891,9 @@ class TrackSerializer(MusicEntitySerializer):
"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
if instance.attributed_to
else None,
} }
if self.context.get("include_ap_context", self.parent is None): if self.context.get("include_ap_context", self.parent is None):
@ -855,13 +903,53 @@ class TrackSerializer(MusicEntitySerializer):
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
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") 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)
return track 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): class UploadSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Audio]) type = serializers.ChoiceField(choices=[contexts.AS.Audio])

View File

@ -64,6 +64,12 @@ class ArtistFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta: class Meta:
model = "music.Artist" 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 @registry.register
class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
@ -79,6 +85,15 @@ class AlbumFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta: class Meta:
model = "music.Album" 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 @registry.register
class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory): class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
@ -94,6 +109,15 @@ class TrackFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Meta: class Meta:
model = "music.Track" 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 @factory.post_generation
def license(self, created, extracted, **kwargs): def license(self, created, extracted, **kwargs):
if not created: if not created:

View File

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

View File

@ -114,6 +114,16 @@ class APIModelMixin(models.Model):
return super().save(**kwargs) 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): class License(models.Model):
code = models.CharField(primary_key=True, max_length=100) code = models.CharField(primary_key=True, max_length=100)
@ -178,6 +188,16 @@ class Artist(APIModelMixin):
"mbid": {"musicbrainz_field_name": "id"}, "mbid": {"musicbrainz_field_name": "id"},
"name": {"musicbrainz_field_name": "name"}, "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 api = musicbrainz.api.artists
objects = ArtistQuerySet.as_manager() objects = ArtistQuerySet.as_manager()
@ -254,6 +274,16 @@ class Album(APIModelMixin):
TYPE_CHOICES = (("album", "Album"),) TYPE_CHOICES = (("album", "Album"),)
type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="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_includes = ["artist-credits", "recordings", "media", "release-groups"]
api = musicbrainz.api.releases api = musicbrainz.api.releases
federation_namespace = "albums" federation_namespace = "albums"
@ -476,6 +506,16 @@ class Track(APIModelMixin):
on_delete=models.DO_NOTHING, on_delete=models.DO_NOTHING,
related_name="tracks", 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) copyright = models.CharField(max_length=500, null=True, blank=True)
federation_namespace = "tracks" federation_namespace = "tracks"
musicbrainz_model = "recording" musicbrainz_model = "recording"

View File

@ -1,14 +1,15 @@
from funkwhale_api.common import mutations from funkwhale_api.common import mutations
from funkwhale_api.federation import routes
from . import models from . import models
def can_suggest(obj, actor): def can_suggest(obj, actor):
return True return obj.is_local
def can_approve(obj, actor): 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( @mutations.registry.connect(
@ -22,3 +23,8 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer):
class Meta: class Meta:
model = models.Track model = models.Track
fields = ["license", "title", "position", "copyright"] fields = ["license", "title", "position", "copyright"]
def post_apply(self, obj, validated_data):
routes.outbox.dispatch(
{"type": "Update", "object": {"type": "Track"}}, context={"track": obj}
)

View File

@ -43,6 +43,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer):
model = models.Album model = models.Album
fields = ( fields = (
"id", "id",
"fid",
"mbid", "mbid",
"title", "title",
"artist", "artist",
@ -51,6 +52,7 @@ class ArtistAlbumSerializer(serializers.ModelSerializer):
"creation_date", "creation_date",
"tracks_count", "tracks_count",
"is_playable", "is_playable",
"is_local",
) )
def get_tracks_count(self, o): def get_tracks_count(self, o):
@ -68,13 +70,13 @@ class ArtistWithAlbumsSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Artist 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 ArtistSimpleSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Artist model = models.Artist
fields = ("id", "mbid", "name", "creation_date") fields = ("id", "fid", "mbid", "name", "creation_date", "is_local")
class AlbumTrackSerializer(serializers.ModelSerializer): class AlbumTrackSerializer(serializers.ModelSerializer):
@ -87,6 +89,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
model = models.Track model = models.Track
fields = ( fields = (
"id", "id",
"fid",
"mbid", "mbid",
"title", "title",
"album", "album",
@ -99,6 +102,7 @@ class AlbumTrackSerializer(serializers.ModelSerializer):
"duration", "duration",
"copyright", "copyright",
"license", "license",
"is_local",
) )
def get_uploads(self, obj): def get_uploads(self, obj):
@ -125,6 +129,7 @@ class AlbumSerializer(serializers.ModelSerializer):
model = models.Album model = models.Album
fields = ( fields = (
"id", "id",
"fid",
"mbid", "mbid",
"title", "title",
"artist", "artist",
@ -133,6 +138,7 @@ class AlbumSerializer(serializers.ModelSerializer):
"cover", "cover",
"creation_date", "creation_date",
"is_playable", "is_playable",
"is_local",
) )
def get_tracks(self, o): def get_tracks(self, o):
@ -156,12 +162,14 @@ class TrackAlbumSerializer(serializers.ModelSerializer):
model = models.Album model = models.Album
fields = ( fields = (
"id", "id",
"fid",
"mbid", "mbid",
"title", "title",
"artist", "artist",
"release_date", "release_date",
"cover", "cover",
"creation_date", "creation_date",
"is_local",
) )
@ -190,6 +198,7 @@ class TrackSerializer(serializers.ModelSerializer):
model = models.Track model = models.Track
fields = ( fields = (
"id", "id",
"fid",
"mbid", "mbid",
"title", "title",
"album", "album",
@ -202,6 +211,7 @@ class TrackSerializer(serializers.ModelSerializer):
"listen_url", "listen_url",
"copyright", "copyright",
"license", "license",
"is_local",
) )
def get_lyrics(self, obj): def get_lyrics(self, obj):

View File

@ -206,7 +206,9 @@ def process_upload(upload):
) )
additional_data["upload_source"] = upload.source additional_data["upload_source"] = upload.source
try: 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: except UploadImportError as e:
return fail_import(upload, e.code) return fail_import(upload, e.code)
except Exception: 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, 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. 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"), "disc_number": payload.get("disc"),
"license": payload.get("license"), "license": payload.get("license"),
"copyright": payload.get("copyright"), "copyright": payload.get("copyright"),
"attributed_to": references.get(payload.get("attributedTo")),
"mbid": str(payload.get("musicbrainzId")) "mbid": str(payload.get("musicbrainzId"))
if payload.get("musicbrainzId") if payload.get("musicbrainzId")
else None, else None,
@ -300,6 +303,7 @@ def federation_audio_track_to_metadata(payload):
"title": payload["album"]["name"], "title": payload["album"]["name"],
"fdate": payload["album"]["published"], "fdate": payload["album"]["published"],
"fid": payload["album"]["id"], "fid": payload["album"]["id"],
"attributed_to": references.get(payload["album"].get("attributedTo")),
"mbid": str(payload["album"]["musicbrainzId"]) "mbid": str(payload["album"]["musicbrainzId"])
if payload["album"].get("musicbrainzId") if payload["album"].get("musicbrainzId")
else None, else None,
@ -309,6 +313,7 @@ def federation_audio_track_to_metadata(payload):
"fid": a["id"], "fid": a["id"],
"name": a["name"], "name": a["name"],
"fdate": a["published"], "fdate": a["published"],
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
} }
for a in payload["album"]["artists"] for a in payload["album"]["artists"]
@ -319,6 +324,7 @@ def federation_audio_track_to_metadata(payload):
"fid": a["id"], "fid": a["id"],
"name": a["name"], "name": a["name"],
"fdate": a["published"], "fdate": a["published"],
"attributed_to": references.get(a.get("attributedTo")),
"mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None, "mbid": str(a["musicbrainzId"]) if a.get("musicbrainzId") else None,
} }
for a in payload["artists"] for a in payload["artists"]
@ -393,8 +399,8 @@ def sort_candidates(candidates, important_fields):
@transaction.atomic @transaction.atomic
def get_track_from_import_metadata(data, update_cover=False): def get_track_from_import_metadata(data, update_cover=False, attributed_to=None):
track = _get_track(data) track = _get_track(data, attributed_to=attributed_to)
if update_cover and track and not track.album.cover: if update_cover and track and not track.album.cover:
update_album_cover( update_album_cover(
track.album, track.album,
@ -404,7 +410,7 @@ def get_track_from_import_metadata(data, update_cover=False):
return track return track
def _get_track(data): def _get_track(data, attributed_to=None):
track_uuid = getter(data, "funkwhale", "track", "uuid") track_uuid = getter(data, "funkwhale", "track", "uuid")
if track_uuid: if track_uuid:
@ -458,6 +464,7 @@ def _get_track(data):
"mbid": artist_mbid, "mbid": artist_mbid,
"fid": artist_fid, "fid": artist_fid,
"from_activity_id": from_activity_id, "from_activity_id": from_activity_id,
"attributed_to": artist.get("attributed_to", attributed_to),
} }
if artist.get("fdate"): if artist.get("fdate"):
defaults["creation_date"] = artist.get("fdate") defaults["creation_date"] = artist.get("fdate")
@ -484,6 +491,7 @@ def _get_track(data):
"mbid": album_artist_mbid, "mbid": album_artist_mbid,
"fid": album_artist_fid, "fid": album_artist_fid,
"from_activity_id": from_activity_id, "from_activity_id": from_activity_id,
"attributed_to": album_artist.get("attributed_to", attributed_to),
} }
if album_artist.get("fdate"): if album_artist.get("fdate"):
defaults["creation_date"] = 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"), "release_date": album.get("release_date"),
"fid": album_fid, "fid": album_fid,
"from_activity_id": from_activity_id, "from_activity_id": from_activity_id,
"attributed_to": album.get("attributed_to", attributed_to),
} }
if album.get("fdate"): if album.get("fdate"):
defaults["creation_date"] = album.get("fdate") defaults["creation_date"] = album.get("fdate")
@ -536,6 +545,7 @@ def _get_track(data):
"disc_number": data.get("disc_number"), "disc_number": data.get("disc_number"),
"fid": track_fid, "fid": track_fid,
"from_activity_id": from_activity_id, "from_activity_id": from_activity_id,
"attributed_to": data.get("attributed_to", attributed_to),
"license": licenses.match(data.get("license"), data.get("copyright")), "license": licenses.match(data.get("license"), data.get("copyright")),
"copyright": data.get("copyright"), "copyright": data.get("copyright"),
} }
@ -613,3 +623,18 @@ def get_prunable_albums():
def get_prunable_artists(): def get_prunable_artists():
return models.Artist.objects.filter(tracks__isnull=True, albums__isnull=True) 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

View File

@ -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") on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
user = factories["users.User"](permission_library=True) user = factories["users.User"](permission_library=True)
actor = user.create_actor() actor = user.create_actor()
track = factories["music.Track"](title="foo") track = factories["music.Track"](title="foo", local=True)
view = V.as_view({"post": "mutations"}) view = V.as_view({"post": "mutations"})
request = api_request.post( request = api_request.post(

View File

@ -10,7 +10,7 @@ def mutations_registry():
return mutations.Registry() return mutations.Registry()
def test_apply_mutation(mutations_registry): def test_apply_mutation(mutations_registry, db):
class Obj: class Obj:
pass pass

View File

@ -1,3 +1,5 @@
import pytest
from funkwhale_api.common import utils from funkwhale_api.common import utils
@ -42,3 +44,44 @@ def test_update_prefix(factories):
old = n.fid old = n.fid
n.refresh_from_db() n.refresh_from_db()
assert n.fid == old.replace("http://", "https://") 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

View File

@ -436,6 +436,53 @@ def test_prepare_deliveries_and_inbox_items(factories):
assert inbox_item.type == "to" 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): def test_should_rotate_actor_key(settings, cache, now):
actor_id = 42 actor_id = 42
settings.ACTOR_KEY_ROTATION_DELAY = 10 settings.ACTOR_KEY_ROTATION_DELAY = 10

View File

@ -134,3 +134,33 @@ def test_actor_stats(factories):
actor = factories["federation.Actor"]() actor = factories["federation.Actor"]()
assert actor.get_stats() == expected 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

View File

@ -1,6 +1,6 @@
import pytest import pytest
from funkwhale_api.federation import jsonld, routes, serializers from funkwhale_api.federation import actors, contexts, jsonld, routes, serializers
@pytest.mark.parametrize( @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": "Library"}, routes.inbox_delete_library),
({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio), ({"type": "Delete", "object.type": "Audio"}, routes.inbox_delete_audio),
({"type": "Undo", "object.type": "Follow"}, routes.inbox_undo_follow), ({"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): 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": "Library"}, routes.outbox_delete_library),
({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio), ({"type": "Delete", "object.type": "Audio"}, routes.outbox_delete_audio),
({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow), ({"type": "Undo", "object.type": "Follow"}, routes.outbox_undo_follow),
({"type": "Update", "object.type": "Track"}, routes.outbox_update_track),
], ],
) )
def test_outbox_routes(route, handler): def test_outbox_routes(route, handler):
@ -405,3 +409,89 @@ def test_outbox_delete_follow_library(factories):
assert activity["actor"] == follow.actor assert activity["actor"] == follow.actor
assert activity["object"] == follow assert activity["object"] == follow
assert activity["related_object"] == follow.target 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()

View File

@ -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 keys
from funkwhale_api.federation import jsonld from funkwhale_api.federation import jsonld
from funkwhale_api.federation import models
from funkwhale_api.federation import serializers 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): def test_actor_serializer_from_ap(db):
private, public = keys.get_key_pair() private, public = keys.get_key_pair()
actor_url = "https://test.federation/actor" actor_url = "https://test.federation/actor"
payload = { payload = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context_fw(),
"id": actor_url, "id": actor_url,
"type": "Person", "type": "Person",
"outbox": "https://test.com/outbox", "outbox": "https://test.com/outbox",
@ -47,3 +57,864 @@ def test_actor_serializer_from_ap(db):
assert actor.private_key is None assert actor.private_key is None
assert actor.public_key == payload["publicKey"]["publicKeyPem"] assert actor.public_key == payload["publicKey"]["publicKeyPem"]
assert actor.domain_id == "test.federation" 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"

View File

@ -533,3 +533,18 @@ def test_queryset_local_entities(factories, settings, factory):
factories[factory](fid="https://noope/3") factories[factory](fid="https://noope/3")
assert list(obj1.__class__.objects.local().order_by("id")) == [obj1, obj2] 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

View File

@ -56,3 +56,16 @@ def test_track_position_mutation(factories):
track.refresh_from_db() track.refresh_from_db()
assert track.position == 12 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}
)

View File

@ -34,6 +34,7 @@ def test_artist_album_serializer(factories, to_api_date):
album = album.__class__.objects.with_tracks_count().get(pk=album.pk) album = album.__class__.objects.with_tracks_count().get(pk=album.pk)
expected = { expected = {
"id": album.id, "id": album.id,
"fid": album.fid,
"mbid": str(album.mbid), "mbid": str(album.mbid),
"title": album.title, "title": album.title,
"artist": album.artist.id, "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, "small_square_crop": album.cover.crop["50x50"].url,
}, },
"release_date": to_api_date(album.release_date), "release_date": to_api_date(album.release_date),
"is_local": album.is_local,
} }
serializer = serializers.ArtistAlbumSerializer(album) serializer = serializers.ArtistAlbumSerializer(album)
@ -61,8 +63,10 @@ def test_artist_with_albums_serializer(factories, to_api_date):
expected = { expected = {
"id": artist.id, "id": artist.id,
"fid": artist.fid,
"mbid": str(artist.mbid), "mbid": str(artist.mbid),
"name": artist.name, "name": artist.name,
"is_local": artist.is_local,
"creation_date": to_api_date(artist.creation_date), "creation_date": to_api_date(artist.creation_date),
"albums": [serializers.ArtistAlbumSerializer(album).data], "albums": [serializers.ArtistAlbumSerializer(album).data],
} }
@ -79,6 +83,7 @@ def test_album_track_serializer(factories, to_api_date):
expected = { expected = {
"id": track.id, "id": track.id,
"fid": track.fid,
"artist": serializers.ArtistSimpleSerializer(track.artist).data, "artist": serializers.ArtistSimpleSerializer(track.artist).data,
"album": track.album.id, "album": track.album.id,
"mbid": str(track.mbid), "mbid": str(track.mbid),
@ -91,6 +96,7 @@ def test_album_track_serializer(factories, to_api_date):
"duration": None, "duration": None,
"license": track.license.code, "license": track.license.code,
"copyright": track.copyright, "copyright": track.copyright,
"is_local": track.is_local,
} }
serializer = serializers.AlbumTrackSerializer(track) serializer = serializers.AlbumTrackSerializer(track)
assert serializer.data == expected assert serializer.data == expected
@ -154,6 +160,7 @@ def test_album_serializer(factories, to_api_date):
album = track1.album album = track1.album
expected = { expected = {
"id": album.id, "id": album.id,
"fid": album.fid,
"mbid": str(album.mbid), "mbid": str(album.mbid),
"title": album.title, "title": album.title,
"artist": serializers.ArtistSimpleSerializer(album.artist).data, "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), "release_date": to_api_date(album.release_date),
"tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data, "tracks": serializers.AlbumTrackSerializer([track2, track1], many=True).data,
"is_local": album.is_local,
} }
serializer = serializers.AlbumSerializer(album) serializer = serializers.AlbumSerializer(album)
@ -181,6 +189,7 @@ def test_track_serializer(factories, to_api_date):
setattr(track, "playable_uploads", [upload]) setattr(track, "playable_uploads", [upload])
expected = { expected = {
"id": track.id, "id": track.id,
"fid": track.fid,
"artist": serializers.ArtistSimpleSerializer(track.artist).data, "artist": serializers.ArtistSimpleSerializer(track.artist).data,
"album": serializers.TrackAlbumSerializer(track.album).data, "album": serializers.TrackAlbumSerializer(track.album).data,
"mbid": str(track.mbid), "mbid": str(track.mbid),
@ -193,6 +202,7 @@ def test_track_serializer(factories, to_api_date):
"listen_url": track.listen_url, "listen_url": track.listen_url,
"license": upload.track.license.code, "license": upload.track.license.code,
"copyright": upload.track.copyright, "copyright": upload.track.copyright,
"is_local": upload.track.is_local,
} }
serializer = serializers.TrackSerializer(track) serializer = serializers.TrackSerializer(track)
assert serializer.data == expected assert serializer.data == expected

View File

@ -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.album.release_date == datetime.date(2012, 8, 15)
assert track.artist.name == metadata["artists"][0]["name"] assert track.artist.name == metadata["artists"][0]["name"]
assert track.artist.mbid is None assert track.artist.mbid is None
assert track.artist.attributed_to is None
match_license.assert_called_once_with(metadata["license"], metadata["copyright"]) 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): def test_can_create_track_from_file_metadata_mbid(factories, mocker):
metadata = { metadata = {
"title": "Test track", "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") outbox = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover") update_album_cover = mocker.patch("funkwhale_api.music.tasks.update_album_cover")
get_picture = mocker.patch("funkwhale_api.music.metadata.Metadata.get_picture") 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="") track = factories["music.Track"](album__cover="")
upload = factories["music.Upload"]( upload = factories["music.Upload"](
track=None, import_metadata={"funkwhale": {"track": {"uuid": str(track.uuid)}}} 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( update_album_cover.assert_called_once_with(
upload.track.album, cover_data=get_picture.return_value, source=upload.source 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( handler.assert_called_once_with(
upload=upload, upload=upload,
old_status="pending", 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 published = now
released = now.date() 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 = { payload = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
"type": "Track", "type": "Track",
@ -492,6 +532,7 @@ def test_federation_audio_track_to_metadata(now):
"published": published.isoformat(), "published": published.isoformat(),
"license": "http://creativecommons.org/licenses/by-sa/4.0/", "license": "http://creativecommons.org/licenses/by-sa/4.0/",
"copyright": "2018 Someone", "copyright": "2018 Someone",
"attributedTo": "http://track.attributed",
"album": { "album": {
"published": published.isoformat(), "published": published.isoformat(),
"type": "Album", "type": "Album",
@ -499,6 +540,7 @@ def test_federation_audio_track_to_metadata(now):
"name": "Purple album", "name": "Purple album",
"musicbrainzId": str(uuid.uuid4()), "musicbrainzId": str(uuid.uuid4()),
"released": released.isoformat(), "released": released.isoformat(),
"attributedTo": "http://album.attributed",
"artists": [ "artists": [
{ {
"type": "Artist", "type": "Artist",
@ -506,6 +548,7 @@ def test_federation_audio_track_to_metadata(now):
"id": "http://hello.artist", "id": "http://hello.artist",
"name": "John Smith", "name": "John Smith",
"musicbrainzId": str(uuid.uuid4()), "musicbrainzId": str(uuid.uuid4()),
"attributedTo": "http://album-artist.attributed",
} }
], ],
"cover": { "cover": {
@ -521,6 +564,7 @@ def test_federation_audio_track_to_metadata(now):
"id": "http://hello.trackartist", "id": "http://hello.trackartist",
"name": "Bob Smith", "name": "Bob Smith",
"musicbrainzId": str(uuid.uuid4()), "musicbrainzId": str(uuid.uuid4()),
"attributedTo": "http://artist.attributed",
} }
], ],
} }
@ -535,8 +579,10 @@ def test_federation_audio_track_to_metadata(now):
"mbid": payload["musicbrainzId"], "mbid": payload["musicbrainzId"],
"fdate": serializer.validated_data["published"], "fdate": serializer.validated_data["published"],
"fid": payload["id"], "fid": payload["id"],
"attributed_to": references["http://track.attributed"],
"album": { "album": {
"title": payload["album"]["name"], "title": payload["album"]["name"],
"attributed_to": references["http://album.attributed"],
"release_date": released, "release_date": released,
"mbid": payload["album"]["musicbrainzId"], "mbid": payload["album"]["musicbrainzId"],
"fid": payload["album"]["id"], "fid": payload["album"]["id"],
@ -546,6 +592,7 @@ def test_federation_audio_track_to_metadata(now):
"name": a["name"], "name": a["name"],
"mbid": a["musicbrainzId"], "mbid": a["musicbrainzId"],
"fid": a["id"], "fid": a["id"],
"attributed_to": references["http://album-artist.attributed"],
"fdate": serializer.validated_data["album"]["artists"][i][ "fdate": serializer.validated_data["album"]["artists"][i][
"published" "published"
], ],
@ -561,6 +608,7 @@ def test_federation_audio_track_to_metadata(now):
"mbid": a["musicbrainzId"], "mbid": a["musicbrainzId"],
"fid": a["id"], "fid": a["id"],
"fdate": serializer.validated_data["artists"][i]["published"], "fdate": serializer.validated_data["artists"][i]["published"],
"attributed_to": references["http://artist.attributed"],
} }
for i, a in enumerate(payload["artists"]) 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 assert result == expected
@ -747,3 +797,14 @@ def test_get_prunable_artists(factories):
factories["music.Track"](album__artist=non_prunable_album_artist) factories["music.Track"](album__artist=non_prunable_album_artist)
assert list(tasks.get_prunable_artists()) == [prunable_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"

View File

@ -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. 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 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 specific activities, and discard unsupported ones.
received and stored though.
If a delivered activity matches one of our routes, a dedicated handler is called, 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 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>`_ - `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 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. 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: .. _activity-delete:
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 - 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 - 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`.

View File

@ -662,12 +662,20 @@ definitions:
type: "integer" type: "integer"
format: "int64" format: "int64"
example: 42 example: 42
fid:
type: string
format: uri
description: "The artist Federation ID (unique accross federation)"
name: name:
type: "string" type: "string"
example: "System of a Down" example: "System of a Down"
creation_date: creation_date:
type: "string" type: "string"
format: "date-time" format: "date-time"
is_local:
type: "boolean"
description: "Indicates if the object was initally created locally or on another server"
Artist: Artist:
type: "object" type: "object"
allOf: allOf:
@ -689,6 +697,10 @@ definitions:
type: "integer" type: "integer"
format: "int64" format: "int64"
example: 16 example: 16
fid:
type: string
format: uri
description: "The album Federation ID (unique accross federation)"
artist: artist:
type: "integer" type: "integer"
format: "int64" format: "int64"
@ -708,6 +720,9 @@ definitions:
type: "boolean" type: "boolean"
cover: cover:
$ref: "#/definitions/Image" $ref: "#/definitions/Image"
is_local:
type: "boolean"
description: "Indicates if the object was initally created locally or on another server"
Album: Album:
type: "object" type: "object"
@ -819,6 +834,10 @@ definitions:
type: "integer" type: "integer"
format: "int64" format: "int64"
example: 66 example: 66
fid:
type: string
format: uri
description: "The track Federation ID (unique accross federation)"
artist: artist:
type: "integer" type: "integer"
format: "int64" format: "int64"
@ -853,6 +872,9 @@ definitions:
type: "string" type: "string"
description: "Identifier of the license that is linked to the track" description: "Identifier of the license that is linked to the track"
example: "cc-by-nc-nd-4.0" 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: AlbumTrack:
type: "object" type: "object"

View File

@ -65,6 +65,7 @@
</modal> </modal>
</template> </template>
<router-link <router-link
v-if="track.is_local"
:to="{name: 'library.tracks.edit', params: {id: track.id }}" :to="{name: 'library.tracks.edit', params: {id: track.id }}"
class="ui icon labeled button"> class="ui icon labeled button">
<i class="edit icon"></i> <i class="edit icon"></i>

View File

@ -63,6 +63,16 @@
<translate translate-context="*/*/*">N/A</translate> <translate translate-context="*/*/*">N/A</translate>
</td> </td>
</tr> </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> </tbody>
</table> </table>
</section> </section>

View File

@ -6,8 +6,11 @@
<translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this track</translate> <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> <translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this track</translate>
</h2> </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 <edit-form
v-if="!isLoadingLicenses" v-else-if="!isLoadingLicenses"
:object-type="objectType" :object-type="objectType"
:object="object" :object="object"
:can-edit="canEdit" :can-edit="canEdit"