mirror of
https://code.eliotberriot.com/funkwhale/funkwhale.git
synced 2025-10-06 10:19:55 +02:00
See #170: Funkwhale federation
This commit is contained in:
parent
fce4d87551
commit
9aa12db62e
20 changed files with 3722 additions and 125 deletions
|
@ -107,6 +107,4 @@ def generate_actor(username, **kwargs):
|
|||
@receiver(post_delete, sender=Channel)
|
||||
def delete_channel_related_objs(instance, **kwargs):
|
||||
instance.library.delete()
|
||||
if instance.actor != instance.attributed_to:
|
||||
instance.actor.delete()
|
||||
instance.artist.delete()
|
||||
|
|
|
@ -13,10 +13,12 @@ from django.utils import timezone
|
|||
from funkwhale_api.common import locales
|
||||
from funkwhale_api.common import permissions
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import routes
|
||||
from funkwhale_api.federation import tasks as federation_tasks
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.music import views as music_views
|
||||
|
@ -128,6 +130,8 @@ class ChannelViewSet(
|
|||
)
|
||||
# prefetch stuff
|
||||
subscription = SubscriptionsViewSet.queryset.get(pk=subscription.pk)
|
||||
if not object.actor.is_local:
|
||||
routes.outbox.dispatch({"type": "Follow"}, context={"follow": subscription})
|
||||
|
||||
data = serializers.SubscriptionSerializer(subscription).data
|
||||
return response.Response(data, status=201)
|
||||
|
@ -139,7 +143,15 @@ class ChannelViewSet(
|
|||
)
|
||||
def unsubscribe(self, request, *args, **kwargs):
|
||||
object = self.get_object()
|
||||
request.user.actor.emitted_follows.filter(target=object.actor).delete()
|
||||
follow_qs = request.user.actor.emitted_follows.filter(target=object.actor)
|
||||
follow = follow_qs.first()
|
||||
if follow:
|
||||
if not object.actor.is_local:
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Undo", "object": {"type": "Follow"}},
|
||||
context={"follow": follow},
|
||||
)
|
||||
follow_qs.delete()
|
||||
return response.Response(status=204)
|
||||
|
||||
@decorators.action(
|
||||
|
@ -248,11 +260,10 @@ class ChannelViewSet(
|
|||
|
||||
@transaction.atomic
|
||||
def perform_destroy(self, instance):
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Delete", "object": {"type": instance.actor.type}},
|
||||
context={"actor": instance.actor},
|
||||
)
|
||||
instance.__class__.objects.filter(pk=instance.pk).delete()
|
||||
common_utils.on_commit(
|
||||
federation_tasks.remove_actor.delay, actor_id=instance.actor.pk
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionsViewSet(
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.utils import timezone
|
|||
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
from funkwhale_api.common import fields as common_fields
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
@ -171,6 +172,7 @@ FETCH_OBJECT_CONFIG = {
|
|||
"library": {"queryset": music_models.Library.objects.all(), "id_attr": "uuid"},
|
||||
"upload": {"queryset": music_models.Upload.objects.all(), "id_attr": "uuid"},
|
||||
"account": {"queryset": models.Actor.objects.all(), "id_attr": "full_username"},
|
||||
"channel": {"queryset": audio_models.Channel.objects.all(), "id_attr": "uuid"},
|
||||
}
|
||||
FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG)
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
from . import schema_org
|
||||
|
||||
CONTEXTS = [
|
||||
{
|
||||
"shortId": "LDP",
|
||||
|
@ -218,6 +220,12 @@ CONTEXTS = [
|
|||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"shortId": "SC",
|
||||
"contextUrl": None,
|
||||
"documentUrl": "http://schema.org",
|
||||
"document": {"@context": schema_org.CONTEXT},
|
||||
},
|
||||
{
|
||||
"shortId": "SEC",
|
||||
"contextUrl": None,
|
||||
|
@ -280,6 +288,7 @@ CONTEXTS = [
|
|||
"type": "@type",
|
||||
"as": "https://www.w3.org/ns/activitystreams#",
|
||||
"fw": "https://funkwhale.audio/ns#",
|
||||
"schema": "http://schema.org#",
|
||||
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
||||
"Album": "fw:Album",
|
||||
"Track": "fw:Track",
|
||||
|
@ -298,6 +307,8 @@ CONTEXTS = [
|
|||
"musicbrainzId": "fw:musicbrainzId",
|
||||
"license": {"@id": "fw:license", "@type": "@id"},
|
||||
"copyright": "fw:copyright",
|
||||
"category": "schema:category",
|
||||
"language": "schema:inLanguage",
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -364,4 +375,5 @@ AS = NS(CONTEXTS_BY_ID["AS"])
|
|||
LDP = NS(CONTEXTS_BY_ID["LDP"])
|
||||
SEC = NS(CONTEXTS_BY_ID["SEC"])
|
||||
FW = NS(CONTEXTS_BY_ID["FW"])
|
||||
SC = NS(CONTEXTS_BY_ID["SC"])
|
||||
LITEPUB = NS(CONTEXTS_BY_ID["LITEPUB"])
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import logging
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
from . import activity
|
||||
|
@ -158,18 +160,26 @@ def outbox_create_audio(context):
|
|||
|
||||
@inbox.register({"type": "Create", "object.type": "Audio"})
|
||||
def inbox_create_audio(payload, context):
|
||||
serializer = serializers.UploadSerializer(
|
||||
data=payload["object"],
|
||||
context={"activity": context.get("activity"), "actor": context["actor"]},
|
||||
)
|
||||
|
||||
is_channel = "library" not in payload["object"]
|
||||
if is_channel:
|
||||
channel = context["actor"].get_channel()
|
||||
serializer = serializers.ChannelUploadSerializer(
|
||||
data=payload["object"], context={"channel": channel},
|
||||
)
|
||||
else:
|
||||
serializer = serializers.UploadSerializer(
|
||||
data=payload["object"],
|
||||
context={"activity": context.get("activity"), "actor": context["actor"]},
|
||||
)
|
||||
if not serializer.is_valid(raise_exception=context.get("raise_exception", False)):
|
||||
logger.warn("Discarding invalid audio create")
|
||||
return
|
||||
|
||||
upload = serializer.save()
|
||||
|
||||
return {"object": upload, "target": upload.library}
|
||||
if is_channel:
|
||||
return {"object": upload, "target": channel}
|
||||
else:
|
||||
return {"object": upload, "target": upload.library}
|
||||
|
||||
|
||||
@inbox.register({"type": "Delete", "object.type": "Library"})
|
||||
|
@ -252,9 +262,10 @@ def inbox_delete_audio(payload, context):
|
|||
# we did not receive a list of Ids, so we can probably use the value directly
|
||||
upload_fids = [payload["object"]["id"]]
|
||||
|
||||
candidates = music_models.Upload.objects.filter(
|
||||
library__actor=actor, fid__in=upload_fids
|
||||
query = Q(fid__in=upload_fids) & (
|
||||
Q(library__actor=actor) | Q(track__artist__channel__actor=actor)
|
||||
)
|
||||
candidates = music_models.Upload.objects.filter(query)
|
||||
|
||||
total = candidates.count()
|
||||
logger.info("Deleting %s uploads with ids %s", total, upload_fids)
|
||||
|
@ -483,3 +494,44 @@ def outbox_flag(context):
|
|||
to=[{"type": "actor_inbox", "actor": report.target_owner}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@inbox.register({"type": "Delete", "object.type": "Album"})
|
||||
def inbox_delete_album(payload, context):
|
||||
actor = context["actor"]
|
||||
album_id = payload["object"].get("id")
|
||||
if not album_id:
|
||||
logger.debug("Discarding deletion of empty library")
|
||||
return
|
||||
|
||||
query = Q(fid=album_id) & (Q(attributed_to=actor) | Q(artist__channel__actor=actor))
|
||||
try:
|
||||
album = music_models.Album.objects.get(query)
|
||||
except music_models.Album.DoesNotExist:
|
||||
logger.debug("Discarding deletion of unkwnown album %s", album_id)
|
||||
return
|
||||
|
||||
album.delete()
|
||||
|
||||
|
||||
@outbox.register({"type": "Delete", "object.type": "Album"})
|
||||
def outbox_delete_album(context):
|
||||
album = context["album"]
|
||||
actor = (
|
||||
album.artist.channel.actor
|
||||
if album.artist.get_channel()
|
||||
else album.attributed_to
|
||||
)
|
||||
actor = actor or actors.get_service_actor()
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "Delete",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
|
2579
api/funkwhale_api/federation/schema_org.py
Normal file
2579
api/funkwhale_api/federation/schema_org.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -5,7 +5,8 @@ import uuid
|
|||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import transaction
|
||||
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
|
@ -23,6 +24,34 @@ from . import activity, actors, contexts, jsonld, models, tasks, utils
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def include_if_not_none(data, value, field):
|
||||
if value is not None:
|
||||
data[field] = value
|
||||
|
||||
|
||||
class MultipleSerializer(serializers.Serializer):
|
||||
"""
|
||||
A serializer that will try multiple serializers in turn
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.allowed = kwargs.pop("allowed")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, v):
|
||||
last_exception = None
|
||||
for serializer_class in self.allowed:
|
||||
s = serializer_class(data=v)
|
||||
try:
|
||||
s.is_valid(raise_exception=True)
|
||||
except serializers.ValidationError as e:
|
||||
last_exception = e
|
||||
else:
|
||||
return s.validated_data
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
class TruncatedCharField(serializers.CharField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.truncate_length = kwargs.pop("truncate_length")
|
||||
|
@ -35,6 +64,38 @@ class TruncatedCharField(serializers.CharField):
|
|||
return v
|
||||
|
||||
|
||||
class TagSerializer(jsonld.JsonLdSerializer):
|
||||
type = serializers.ChoiceField(choices=[contexts.AS.Hashtag])
|
||||
name = serializers.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = {"name": jsonld.first_val(contexts.AS.name)}
|
||||
|
||||
def validate_name(self, value):
|
||||
if value.startswith("#"):
|
||||
# remove trailing #
|
||||
value = value[1:]
|
||||
return value
|
||||
|
||||
|
||||
def tag_list(tagged_items):
|
||||
return [
|
||||
repr_tag(item.tag.name)
|
||||
for item in sorted(set(tagged_items.all()), key=lambda i: i.tag.name)
|
||||
]
|
||||
|
||||
|
||||
def is_mimetype(mt, allowed_mimetypes):
|
||||
for allowed in allowed_mimetypes:
|
||||
if allowed.endswith("/*"):
|
||||
if mt.startswith(allowed.replace("*", "")):
|
||||
return True
|
||||
else:
|
||||
if mt == allowed:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MediaSerializer(jsonld.JsonLdSerializer):
|
||||
mediaType = serializers.CharField()
|
||||
|
||||
|
@ -52,30 +113,51 @@ class MediaSerializer(jsonld.JsonLdSerializer):
|
|||
if self.allow_empty_mimetype and not v:
|
||||
return None
|
||||
|
||||
for mt in self.allowed_mimetypes:
|
||||
|
||||
if mt.endswith("/*"):
|
||||
if v.startswith(mt.replace("*", "")):
|
||||
return v
|
||||
else:
|
||||
if v == mt:
|
||||
return v
|
||||
raise serializers.ValidationError(
|
||||
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
|
||||
)
|
||||
if not is_mimetype(v, self.allowed_mimetypes):
|
||||
raise serializers.ValidationError(
|
||||
"Invalid mimetype {}. Allowed: {}".format(v, self.allowed_mimetypes)
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class LinkSerializer(MediaSerializer):
|
||||
type = serializers.ChoiceField(choices=[contexts.AS.Link])
|
||||
href = serializers.URLField(max_length=500)
|
||||
bitrate = serializers.IntegerField(min_value=0, required=False)
|
||||
size = serializers.IntegerField(min_value=0, required=False)
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = {
|
||||
"href": jsonld.first_id(contexts.AS.href),
|
||||
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
||||
"bitrate": jsonld.first_val(contexts.FW.bitrate),
|
||||
"size": jsonld.first_val(contexts.FW.size),
|
||||
}
|
||||
|
||||
|
||||
class LinkListSerializer(serializers.ListField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("child", LinkSerializer(jsonld_expand=False))
|
||||
self.keep_mediatype = kwargs.pop("keep_mediatype", [])
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, v):
|
||||
links = super().to_internal_value(v)
|
||||
if not self.keep_mediatype:
|
||||
# no further filtering required
|
||||
return links
|
||||
links = [
|
||||
link
|
||||
for link in links
|
||||
if link.get("mediaType")
|
||||
and is_mimetype(link["mediaType"], self.keep_mediatype)
|
||||
]
|
||||
if not self.allow_empty and len(links) == 0:
|
||||
self.fail("empty")
|
||||
|
||||
return links
|
||||
|
||||
|
||||
class ImageSerializer(MediaSerializer):
|
||||
type = serializers.ChoiceField(choices=[contexts.AS.Image, contexts.AS.Link])
|
||||
href = serializers.URLField(max_length=500, required=False)
|
||||
|
@ -133,6 +215,16 @@ def get_by_media_type(urls, media_type):
|
|||
return url
|
||||
|
||||
|
||||
class BasicActorSerializer(jsonld.JsonLdSerializer):
|
||||
id = serializers.URLField(max_length=500)
|
||||
type = serializers.ChoiceField(
|
||||
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = {}
|
||||
|
||||
|
||||
class ActorSerializer(jsonld.JsonLdSerializer):
|
||||
id = serializers.URLField(max_length=500)
|
||||
outbox = serializers.URLField(max_length=500, required=False)
|
||||
|
@ -163,6 +255,16 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
|||
required=False,
|
||||
allow_empty_mimetype=True,
|
||||
)
|
||||
attributedTo = serializers.URLField(max_length=500, required=False)
|
||||
|
||||
tags = serializers.ListField(
|
||||
child=TagSerializer(), min_length=0, required=False, allow_null=True
|
||||
)
|
||||
|
||||
category = serializers.CharField(required=False)
|
||||
# languages = serializers.Char(
|
||||
# music_models.ARTIST_CONTENT_CATEGORY_CHOICES, required=False, default="music",
|
||||
# )
|
||||
|
||||
class Meta:
|
||||
# not strictly necessary because it's not a model serializer
|
||||
|
@ -185,8 +287,19 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
|||
"endpoints": jsonld.first_obj(contexts.AS.endpoints),
|
||||
"icon": jsonld.first_obj(contexts.AS.icon),
|
||||
"url": jsonld.raw(contexts.AS.url),
|
||||
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
|
||||
"tags": jsonld.raw(contexts.AS.tag),
|
||||
"category": jsonld.first_val(contexts.SC.category),
|
||||
# "language": jsonld.first_val(contexts.SC.inLanguage),
|
||||
}
|
||||
|
||||
def validate_category(self, v):
|
||||
return (
|
||||
v
|
||||
if v in [t for t, _ in music_models.ARTIST_CONTENT_CATEGORY_CHOICES]
|
||||
else None
|
||||
)
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = {
|
||||
"id": instance.fid,
|
||||
|
@ -231,6 +344,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
|||
include_image(ret, channel.artist.attachment_cover, "icon")
|
||||
if channel.artist.description_id:
|
||||
ret["summary"] = channel.artist.description.rendered
|
||||
ret["attributedTo"] = channel.attributed_to.fid
|
||||
ret["category"] = channel.artist.content_category
|
||||
ret["tag"] = tag_list(channel.artist.tagged_items.all())
|
||||
else:
|
||||
ret["url"] = [
|
||||
{
|
||||
|
@ -312,6 +428,22 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
|||
if new_value
|
||||
else None,
|
||||
)
|
||||
|
||||
rss_url = get_by_media_type(
|
||||
self.validated_data.get("url", []), "application/rss+xml"
|
||||
)
|
||||
if rss_url:
|
||||
rss_url = rss_url["href"]
|
||||
attributed_to = self.validated_data.get("attributedTo")
|
||||
if rss_url and attributed_to:
|
||||
# if the actor is attributed to another actor, and there is a RSS url,
|
||||
# then we consider it's a channel
|
||||
create_or_update_channel(
|
||||
actor,
|
||||
rss_url=rss_url,
|
||||
attributed_to_fid=attributed_to,
|
||||
**self.validated_data
|
||||
)
|
||||
return actor
|
||||
|
||||
def validate(self, data):
|
||||
|
@ -326,6 +458,56 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
|||
return validated_data
|
||||
|
||||
|
||||
def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data):
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
|
||||
attributed_to = actors.get_actor(attributed_to_fid)
|
||||
artist_defaults = {
|
||||
"name": validated_data.get("name", validated_data["preferredUsername"]),
|
||||
"fid": validated_data["id"],
|
||||
"content_category": validated_data.get("category", "music") or "music",
|
||||
"attributed_to": attributed_to,
|
||||
}
|
||||
artist, created = music_models.Artist.objects.update_or_create(
|
||||
channel__attributed_to=attributed_to,
|
||||
channel__actor=actor,
|
||||
defaults=artist_defaults,
|
||||
)
|
||||
common_utils.attach_content(artist, "description", validated_data.get("summary"))
|
||||
if "icon" in validated_data:
|
||||
new_value = validated_data["icon"]
|
||||
common_utils.attach_file(
|
||||
artist,
|
||||
"attachment_cover",
|
||||
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
|
||||
if new_value
|
||||
else None,
|
||||
)
|
||||
tags = [t["name"] for t in validated_data.get("tags", []) or []]
|
||||
tags_models.set_tags(artist, *tags)
|
||||
if created:
|
||||
uid = uuid.uuid4()
|
||||
fid = utils.full_url(
|
||||
reverse("federation:music:libraries-detail", kwargs={"uuid": uid})
|
||||
)
|
||||
library = attributed_to.libraries.create(
|
||||
privacy_level="everyone", name=artist_defaults["name"], fid=fid, uuid=uid,
|
||||
)
|
||||
else:
|
||||
library = artist.channel.library
|
||||
channel_defaults = {
|
||||
"actor": actor,
|
||||
"attributed_to": attributed_to,
|
||||
"rss_url": rss_url,
|
||||
"artist": artist,
|
||||
"library": library,
|
||||
}
|
||||
channel, created = audio_models.Channel.objects.update_or_create(
|
||||
actor=actor, attributed_to=attributed_to, defaults=channel_defaults,
|
||||
)
|
||||
return channel
|
||||
|
||||
|
||||
class APIActorSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
|
@ -936,20 +1118,6 @@ MUSIC_ENTITY_JSONLD_MAPPING = {
|
|||
}
|
||||
|
||||
|
||||
class TagSerializer(jsonld.JsonLdSerializer):
|
||||
type = serializers.ChoiceField(choices=[contexts.AS.Hashtag])
|
||||
name = serializers.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = {"name": jsonld.first_val(contexts.AS.name)}
|
||||
|
||||
def validate_name(self, value):
|
||||
if value.startswith("#"):
|
||||
# remove trailing #
|
||||
value = value[1:]
|
||||
return value
|
||||
|
||||
|
||||
def repr_tag(tag_name):
|
||||
return {"type": "Hashtag", "name": "#{}".format(tag_name)}
|
||||
|
||||
|
@ -1025,10 +1193,7 @@ class MusicEntitySerializer(jsonld.JsonLdSerializer):
|
|||
return instance
|
||||
|
||||
def get_tags_repr(self, instance):
|
||||
return [
|
||||
repr_tag(item.tag.name)
|
||||
for item in sorted(instance.tagged_items.all(), key=lambda i: i.tag.name)
|
||||
]
|
||||
return tag_list(instance.tagged_items.all())
|
||||
|
||||
def validate_updated_data(self, instance, validated_data):
|
||||
try:
|
||||
|
@ -1108,7 +1273,10 @@ class ArtistSerializer(MusicEntitySerializer):
|
|||
|
||||
class AlbumSerializer(MusicEntitySerializer):
|
||||
released = serializers.DateField(allow_null=True, required=False)
|
||||
artists = serializers.ListField(child=ArtistSerializer(), min_length=1)
|
||||
artists = serializers.ListField(
|
||||
child=MultipleSerializer(allowed=[BasicActorSerializer, ArtistSerializer]),
|
||||
min_length=1,
|
||||
)
|
||||
# XXX: 1.0 rename to image
|
||||
cover = ImageSerializer(
|
||||
allowed_mimetypes=["image/*"],
|
||||
|
@ -1146,16 +1314,24 @@ class AlbumSerializer(MusicEntitySerializer):
|
|||
"released": instance.release_date.isoformat()
|
||||
if instance.release_date
|
||||
else None,
|
||||
"artists": [
|
||||
ArtistSerializer(
|
||||
instance.artist, context={"include_ap_context": False}
|
||||
).data
|
||||
],
|
||||
"attributedTo": instance.attributed_to.fid
|
||||
if instance.attributed_to
|
||||
else None,
|
||||
"tag": self.get_tags_repr(instance),
|
||||
}
|
||||
if instance.artist.get_channel():
|
||||
d["artists"] = [
|
||||
{
|
||||
"type": instance.artist.channel.actor.type,
|
||||
"id": instance.artist.channel.actor.fid,
|
||||
}
|
||||
]
|
||||
else:
|
||||
d["artists"] = [
|
||||
ArtistSerializer(
|
||||
instance.artist, context={"include_ap_context": False}
|
||||
).data
|
||||
]
|
||||
include_content(d, instance.description)
|
||||
if instance.attachment_cover:
|
||||
d["cover"] = {
|
||||
|
@ -1172,12 +1348,18 @@ class AlbumSerializer(MusicEntitySerializer):
|
|||
def validate(self, data):
|
||||
validated_data = super().validate(data)
|
||||
if not self.parent:
|
||||
validated_data["_artist"] = utils.retrieve_ap_object(
|
||||
validated_data["artists"][0]["id"],
|
||||
actor=self.context.get("fetch_actor"),
|
||||
queryset=music_models.Artist,
|
||||
serializer_class=ArtistSerializer,
|
||||
)
|
||||
artist_data = validated_data["artists"][0]
|
||||
if artist_data.get("type", "Artist") == "Artist":
|
||||
validated_data["_artist"] = utils.retrieve_ap_object(
|
||||
artist_data["id"],
|
||||
actor=self.context.get("fetch_actor"),
|
||||
queryset=music_models.Artist,
|
||||
serializer_class=ArtistSerializer,
|
||||
)
|
||||
else:
|
||||
# we have an actor as an artist, so it's a channel
|
||||
actor = actors.get_actor(artist_data["id"])
|
||||
validated_data["_artist"] = actor.channel.artist
|
||||
|
||||
return validated_data
|
||||
|
||||
|
@ -1569,31 +1751,116 @@ class ChannelOutboxSerializer(PaginatedCollectionSerializer):
|
|||
return r
|
||||
|
||||
|
||||
class ChannelUploadSerializer(serializers.Serializer):
|
||||
class ChannelUploadSerializer(jsonld.JsonLdSerializer):
|
||||
id = serializers.URLField(max_length=500)
|
||||
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
|
||||
url = LinkListSerializer(keep_mediatype=["audio/*"], min_length=1)
|
||||
name = TruncatedCharField(truncate_length=music_models.MAX_LENGTHS["TRACK_TITLE"])
|
||||
published = serializers.DateTimeField(required=False)
|
||||
duration = serializers.IntegerField(min_value=0, required=False)
|
||||
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
|
||||
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
|
||||
album = serializers.URLField(max_length=500, required=False)
|
||||
license = serializers.URLField(allow_null=True, required=False)
|
||||
copyright = TruncatedCharField(
|
||||
truncate_length=music_models.MAX_LENGTHS["COPYRIGHT"],
|
||||
allow_null=True,
|
||||
required=False,
|
||||
)
|
||||
image = ImageSerializer(
|
||||
allowed_mimetypes=["image/*"],
|
||||
allow_null=True,
|
||||
required=False,
|
||||
allow_empty_mimetype=True,
|
||||
)
|
||||
|
||||
mediaType = serializers.ChoiceField(
|
||||
choices=common_models.CONTENT_TEXT_SUPPORTED_TYPES,
|
||||
default="text/html",
|
||||
required=False,
|
||||
)
|
||||
content = TruncatedCharField(
|
||||
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
tags = serializers.ListField(
|
||||
child=TagSerializer(), min_length=0, required=False, allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
jsonld_mapping = {
|
||||
"name": jsonld.first_val(contexts.AS.name),
|
||||
"url": jsonld.raw(contexts.AS.url),
|
||||
"published": jsonld.first_val(contexts.AS.published),
|
||||
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
||||
"content": jsonld.first_val(contexts.AS.content),
|
||||
"duration": jsonld.first_val(contexts.AS.duration),
|
||||
"album": jsonld.first_id(contexts.FW.album),
|
||||
"copyright": jsonld.first_val(contexts.FW.copyright),
|
||||
"disc": jsonld.first_val(contexts.FW.disc),
|
||||
"license": jsonld.first_id(contexts.FW.license),
|
||||
"position": jsonld.first_val(contexts.FW.position),
|
||||
"image": jsonld.first_obj(contexts.AS.image),
|
||||
"tags": jsonld.raw(contexts.AS.tag),
|
||||
}
|
||||
|
||||
def validate_album(self, v):
|
||||
return utils.retrieve_ap_object(
|
||||
v,
|
||||
actor=actors.get_service_actor(),
|
||||
serializer_class=AlbumSerializer,
|
||||
queryset=music_models.Album.objects.filter(
|
||||
artist__channel=self.context["channel"]
|
||||
),
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
validated_data = super().validate(data)
|
||||
if data.get("content"):
|
||||
validated_data["description"] = {
|
||||
"content_type": data["mediaType"],
|
||||
"text": data["content"],
|
||||
}
|
||||
return validated_data
|
||||
|
||||
def to_representation(self, upload):
|
||||
data = {
|
||||
"id": upload.fid,
|
||||
"type": "Audio",
|
||||
"name": upload.track.full_name,
|
||||
"name": upload.track.title,
|
||||
"attributedTo": upload.library.channel.actor.fid,
|
||||
"published": upload.creation_date.isoformat(),
|
||||
"to": contexts.AS.Public
|
||||
if upload.library.privacy_level == "everyone"
|
||||
else "",
|
||||
"url": [
|
||||
{
|
||||
"type": "Link",
|
||||
"mediaType": upload.mimetype,
|
||||
"href": utils.full_url(upload.listen_url_no_download),
|
||||
},
|
||||
{
|
||||
"type": "Link",
|
||||
"mediaType": "text/html",
|
||||
"href": utils.full_url(upload.track.get_absolute_url()),
|
||||
},
|
||||
{
|
||||
"type": "Link",
|
||||
"mediaType": upload.mimetype,
|
||||
"href": utils.full_url(upload.listen_url_no_download),
|
||||
},
|
||||
],
|
||||
}
|
||||
if upload.track.album:
|
||||
data["album"] = upload.track.album.fid
|
||||
if upload.track.local_license:
|
||||
data["license"] = upload.track.local_license["identifiers"][0]
|
||||
|
||||
include_if_not_none(data, upload.duration, "duration")
|
||||
include_if_not_none(data, upload.track.position, "position")
|
||||
include_if_not_none(data, upload.track.disc_number, "disc")
|
||||
include_if_not_none(data, upload.track.copyright, "copyright")
|
||||
include_if_not_none(data["url"][1], upload.bitrate, "bitrate")
|
||||
include_if_not_none(data["url"][1], upload.size, "size")
|
||||
include_content(data, upload.track.description)
|
||||
include_image(data, upload.track.attachment_cover)
|
||||
tags = [item.tag.name for item in upload.get_all_tagged_items()]
|
||||
if tags:
|
||||
data["tag"] = [repr_tag(name) for name in tags]
|
||||
|
@ -1604,6 +1871,68 @@ class ChannelUploadSerializer(serializers.Serializer):
|
|||
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
return self.update_or_create(validated_data)
|
||||
|
||||
@transaction.atomic
|
||||
def update_or_create(self, validated_data):
|
||||
channel = self.context["channel"]
|
||||
now = timezone.now()
|
||||
track_defaults = {
|
||||
"fid": validated_data["id"],
|
||||
"artist": channel.artist,
|
||||
"position": validated_data.get("position", 1),
|
||||
"disc_number": validated_data.get("disc", 1),
|
||||
"title": validated_data["name"],
|
||||
"copyright": validated_data.get("copyright"),
|
||||
"attributed_to": channel.attributed_to,
|
||||
"album": validated_data.get("album"),
|
||||
"creation_date": validated_data.get("published", now),
|
||||
}
|
||||
if validated_data.get("license"):
|
||||
track_defaults["license"] = licenses.match(validated_data["license"])
|
||||
|
||||
track, created = music_models.Track.objects.update_or_create(
|
||||
artist__channel=channel, fid=validated_data["id"], defaults=track_defaults
|
||||
)
|
||||
|
||||
if "image" in validated_data:
|
||||
new_value = self.validated_data["image"]
|
||||
common_utils.attach_file(
|
||||
track,
|
||||
"attachment_cover",
|
||||
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
|
||||
if new_value
|
||||
else None,
|
||||
)
|
||||
|
||||
common_utils.attach_content(
|
||||
track, "description", validated_data.get("description")
|
||||
)
|
||||
|
||||
tags = [t["name"] for t in validated_data.get("tags", []) or []]
|
||||
tags_models.set_tags(track, *tags)
|
||||
|
||||
upload_defaults = {
|
||||
"fid": validated_data["id"],
|
||||
"track": track,
|
||||
"library": channel.library,
|
||||
"creation_date": validated_data.get("published", now),
|
||||
"duration": validated_data.get("duration"),
|
||||
"bitrate": validated_data["url"][0].get("bitrate"),
|
||||
"size": validated_data["url"][0].get("size"),
|
||||
"mimetype": validated_data["url"][0]["mediaType"],
|
||||
"source": validated_data["url"][0]["href"],
|
||||
"import_status": "finished",
|
||||
}
|
||||
upload, created = music_models.Upload.objects.update_or_create(
|
||||
fid=validated_data["id"], defaults=upload_defaults
|
||||
)
|
||||
return upload
|
||||
|
||||
def create(self, validated_data):
|
||||
return self.update_or_create(validated_data)
|
||||
|
||||
|
||||
class ChannelCreateUploadSerializer(serializers.Serializer):
|
||||
def to_representation(self, upload):
|
||||
|
|
|
@ -7,11 +7,14 @@ import requests
|
|||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Q, F
|
||||
from django.db.models.deletion import Collector
|
||||
from django.utils import timezone
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from funkwhale_api.audio import models as audio_models
|
||||
from funkwhale_api.common import preferences
|
||||
from funkwhale_api.common import models as common_models
|
||||
from funkwhale_api.common import session
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.moderation import mrf
|
||||
|
@ -254,8 +257,11 @@ def handle_purge_actors(ids, only=[]):
|
|||
|
||||
# purge audio content
|
||||
if not only or "media" in only:
|
||||
delete_qs(common_models.Attachment.objects.filter(actor__in=ids))
|
||||
delete_qs(models.LibraryFollow.objects.filter(actor_id__in=ids))
|
||||
delete_qs(models.Follow.objects.filter(target_id__in=ids))
|
||||
delete_qs(audio_models.Channel.objects.filter(attributed_to__in=ids))
|
||||
delete_qs(audio_models.Channel.objects.filter(actor__in=ids))
|
||||
delete_qs(music_models.Upload.objects.filter(library__actor_id__in=ids))
|
||||
delete_qs(music_models.Library.objects.filter(actor_id__in=ids))
|
||||
|
||||
|
@ -390,9 +396,76 @@ def fetch(fetch_obj):
|
|||
error("save", message=str(e))
|
||||
raise
|
||||
|
||||
# special case for channels
|
||||
# when obj is an actor, we check if the actor has a channel associated with it
|
||||
# if it is the case, we consider the fetch obj to be a channel instead
|
||||
if isinstance(obj, models.Actor) and obj.get_channel():
|
||||
obj = obj.get_channel()
|
||||
fetch_obj.object = obj
|
||||
fetch_obj.status = "finished"
|
||||
fetch_obj.fetch_date = timezone.now()
|
||||
return fetch_obj.save(
|
||||
update_fields=["fetch_date", "status", "object_id", "object_content_type"]
|
||||
)
|
||||
|
||||
|
||||
class PreserveSomeDataCollector(Collector):
|
||||
"""
|
||||
We need to delete everything related to an actor. Well… Almost everything.
|
||||
But definitely not the Delete Activity we send to announce the actor is deleted.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.creation_date = timezone.now()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def related_objects(self, related, *args, **kwargs):
|
||||
qs = super().related_objects(related, *args, **kwargs)
|
||||
if related.name == "outbox_activities":
|
||||
# exclude the delete activity can be broadcasted properly
|
||||
qs = qs.exclude(type="Delete", creation_date__gte=self.creation_date)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
@celery.app.task(name="federation.remove_actor")
|
||||
@transaction.atomic
|
||||
@celery.require_instance(
|
||||
models.Actor.objects.all(), "actor",
|
||||
)
|
||||
def remove_actor(actor):
|
||||
# Then we broadcast the info over federation. We do this *before* deleting objects
|
||||
# associated with the actor, otherwise follows are removed and we don't know where
|
||||
# to broadcast
|
||||
logger.info("Broadcasting deletion to federation…")
|
||||
collector = PreserveSomeDataCollector(using="default")
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
|
||||
)
|
||||
|
||||
# then we delete any object associated with the actor object, but *not* the actor
|
||||
# itself. We keep it for auditability and sending the Delete ActivityPub message
|
||||
logger.info(
|
||||
"Prepare deletion of objects associated with account %s…",
|
||||
actor.preferred_username,
|
||||
)
|
||||
collector.collect([actor])
|
||||
for model, instances in collector.data.items():
|
||||
if issubclass(model, actor.__class__):
|
||||
# we skip deletion of the actor itself
|
||||
continue
|
||||
|
||||
to_delete = model.objects.filter(pk__in=[instance.pk for instance in instances])
|
||||
logger.info(
|
||||
"Deleting %s objects associated with account %s…",
|
||||
len(instances),
|
||||
actor.preferred_username,
|
||||
)
|
||||
to_delete.delete()
|
||||
|
||||
# Finally, we update the actor itself and mark it as removed
|
||||
logger.info("Marking actor as Tombsone…")
|
||||
actor.type = "Tombstone"
|
||||
actor.name = None
|
||||
actor.summary = None
|
||||
actor.save(update_fields=["type", "name", "summary"])
|
||||
|
|
|
@ -67,7 +67,11 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
|
|||
lookup_field = "preferred_username"
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = models.Actor.objects.local().select_related("user")
|
||||
queryset = (
|
||||
models.Actor.objects.local()
|
||||
.select_related("user", "channel__artist", "channel__attributed_to")
|
||||
.prefetch_related("channel__artist__tagged_items__tag")
|
||||
)
|
||||
serializer_class = serializers.ActorSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
|
@ -241,6 +241,14 @@ class AlbumViewSet(
|
|||
return serializers.AlbumCreateSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
@transaction.atomic
|
||||
def perform_destroy(self, instance):
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Delete", "object": {"type": "Album"}},
|
||||
context={"album": instance},
|
||||
)
|
||||
models.Album.objects.filter(pk=instance.pk).delete()
|
||||
|
||||
|
||||
class LibraryViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
|
@ -380,6 +388,15 @@ class TrackViewSet(
|
|||
context["description"] = self.action in ["retrieve", "create", "update"]
|
||||
return context
|
||||
|
||||
@transaction.atomic
|
||||
def perform_destroy(self, instance):
|
||||
uploads = instance.uploads.order_by("id")
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Delete", "object": {"type": "Audio"}},
|
||||
context={"uploads": list(uploads)},
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
|
||||
def strip_absolute_media_url(path):
|
||||
if (
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import logging
|
||||
|
||||
from django.db.models.deletion import Collector
|
||||
|
||||
from funkwhale_api.federation import routes
|
||||
from funkwhale_api.federation import tasks as federation_tasks
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
from . import models
|
||||
|
@ -20,39 +18,6 @@ def delete_account(user):
|
|||
user.delete()
|
||||
logger.info("Deleted user object")
|
||||
|
||||
# Then we broadcast the info over federation. We do this *before* deleting objects
|
||||
# associated with the actor, otherwise follows are removed and we don't know where
|
||||
# to broadcast
|
||||
logger.info("Broadcasting deletion to federation…")
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Delete", "object": {"type": actor.type}}, context={"actor": actor}
|
||||
)
|
||||
|
||||
# then we delete any object associated with the actor object, but *not* the actor
|
||||
# itself. We keep it for auditability and sending the Delete ActivityPub message
|
||||
collector = Collector(using="default")
|
||||
logger.info(
|
||||
"Prepare deletion of objects associated with account %s…", user.username
|
||||
)
|
||||
collector.collect([actor])
|
||||
|
||||
for model, instances in collector.data.items():
|
||||
if issubclass(model, actor.__class__):
|
||||
# we skip deletion of the actor itself
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Deleting %s objects associated with account %s…",
|
||||
len(instances),
|
||||
user.username,
|
||||
)
|
||||
to_delete = model.objects.filter(pk__in=[instance.pk for instance in instances])
|
||||
to_delete.delete()
|
||||
|
||||
# Finally, we update the actor itself and mark it as removed
|
||||
logger.info("Marking actor as Tombsone…")
|
||||
actor.type = "Tombstone"
|
||||
actor.name = None
|
||||
actor.summary = None
|
||||
actor.save(update_fields=["type", "name", "summary"])
|
||||
logger.info("Deletion of account done %s!", user.username)
|
||||
# ensure actor is set to tombstone, activities are removed, etc.
|
||||
federation_tasks.remove_actor(actor_id=actor.pk)
|
||||
logger.info("Deletion of account done %s!", actor.preferred_username)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue