Merge branch '170-funkwhale-federation' into 'develop'

See #170: Funkwhale federation

See merge request funkwhale/funkwhale!1066
This commit is contained in:
Eliot Berriot 2020-03-25 15:32:10 +01:00
commit 464010f046
20 changed files with 3722 additions and 125 deletions

View File

@ -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()

View File

@ -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(

View File

@ -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)

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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):

View File

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

View File

@ -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):

View File

@ -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 (

View File

@ -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)

View File

@ -148,19 +148,17 @@ def test_channel_delete(logged_in_api_client, factories, mocker):
channel = factories["audio.Channel"](attributed_to=actor)
url = reverse("api:v1:channels-detail", kwargs={"composite": channel.uuid})
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
response = logged_in_api_client.delete(url)
assert response.status_code == 204
on_commit.assert_called_once_with(
views.federation_tasks.remove_actor.delay, actor_id=channel.actor.pk
)
with pytest.raises(channel.DoesNotExist):
channel.refresh_from_db()
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": channel.actor.type}},
context={"actor": channel.actor},
)
def test_channel_delete_permission(logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
@ -218,6 +216,38 @@ def test_channel_unsubscribe(factories, logged_in_api_client):
subscription.refresh_from_db()
def test_channel_subscribe_remote(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
channel_actor = factories["federation.Actor"]()
channel = factories["audio.Channel"](artist__description=None, actor=channel_actor)
url = reverse("api:v1:channels-subscribe", kwargs={"composite": channel.uuid})
response = logged_in_api_client.post(url)
assert response.status_code == 201
subscription = actor.emitted_follows.latest("id")
dispatch.assert_called_once_with(
{"type": "Follow"}, context={"follow": subscription}
)
def test_channel_unsubscribe_remote(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
channel_actor = factories["federation.Actor"]()
channel = factories["audio.Channel"](actor=channel_actor)
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
url = reverse("api:v1:channels-unsubscribe", kwargs={"composite": channel.uuid})
response = logged_in_api_client.post(url)
assert response.status_code == 204
dispatch.assert_called_once_with(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": subscription}
)
def test_subscriptions_list(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](

View File

@ -167,6 +167,7 @@ def test_fetch_serializer_no_obj(factories, to_api_date):
("music.Track", "track", "id"),
("music.Library", "library", "uuid"),
("music.Upload", "upload", "uuid"),
("audio.Channel", "channel", "uuid"),
("federation.Actor", "account", "full_username"),
],
)

View File

@ -26,6 +26,7 @@ from funkwhale_api.moderation import serializers as moderation_serializers
routes.inbox_delete_library,
),
({"type": "Delete", "object": {"type": "Audio"}}, routes.inbox_delete_audio),
({"type": "Delete", "object": {"type": "Album"}}, routes.inbox_delete_album),
({"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),
@ -58,6 +59,7 @@ def test_inbox_routes(route, handler):
routes.outbox_delete_library,
),
({"type": "Delete", "object": {"type": "Audio"}}, routes.outbox_delete_audio),
({"type": "Delete", "object": {"type": "Album"}}, routes.outbox_delete_album),
({"type": "Undo", "object": {"type": "Follow"}}, routes.outbox_undo_follow),
({"type": "Update", "object": {"type": "Track"}}, routes.outbox_update_track),
(
@ -349,6 +351,34 @@ def test_inbox_create_audio(factories, mocker):
assert save.call_count == 1
def test_inbox_create_audio_channel(factories, mocker):
activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
upload = factories["music.Upload"](track__album=album, library=channel.library,)
payload = {
"@context": jsonld.get_default_context(),
"type": "Create",
"actor": channel.actor.fid,
"object": serializers.ChannelUploadSerializer(upload).data,
}
upload.delete()
init = mocker.spy(serializers.ChannelUploadSerializer, "__init__")
save = mocker.spy(serializers.ChannelUploadSerializer, "save")
result = routes.inbox_create_audio(
payload,
context={"actor": channel.actor, "raise_exception": True, "activity": activity},
)
assert channel.library.uploads.count() == 1
assert result == {"object": channel.library.uploads.latest("id"), "target": channel}
assert init.call_count == 1
args = init.call_args
assert args[1]["data"] == payload["object"]
assert args[1]["context"] == {"channel": channel}
assert save.call_count == 1
def test_inbox_delete_library(factories):
activity = factories["federation.Activity"]()
@ -368,6 +398,73 @@ def test_inbox_delete_library(factories):
library.refresh_from_db()
def test_inbox_delete_album(factories):
album = factories["music.Album"](attributed=True)
payload = {
"type": "Delete",
"actor": album.attributed_to.fid,
"object": {"type": "Album", "id": album.fid},
}
routes.inbox_delete_album(
payload,
context={
"actor": album.attributed_to,
"raise_exception": True,
"activity": activity,
},
)
with pytest.raises(album.__class__.DoesNotExist):
album.refresh_from_db()
def test_inbox_delete_album_channel(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
payload = {
"type": "Delete",
"actor": channel.actor.fid,
"object": {"type": "Album", "id": album.fid},
}
routes.inbox_delete_album(
payload,
context={"actor": channel.actor, "raise_exception": True, "activity": activity},
)
with pytest.raises(album.__class__.DoesNotExist):
album.refresh_from_db()
def test_outbox_delete_album(factories):
album = factories["music.Album"](attributed=True)
a = list(routes.outbox_delete_album({"album": album}))[0]
expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
).data
expected["to"] = [activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}]
assert dict(a["payload"]) == dict(expected)
assert a["actor"] == album.attributed_to
def test_outbox_delete_album_channel(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist)
a = list(routes.outbox_delete_album({"album": album}))[0]
expected = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Album", "id": album.fid}}
).data
expected["to"] = [activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}]
assert dict(a["payload"]) == dict(expected)
assert a["actor"] == channel.actor
def test_inbox_delete_library_impostor(factories):
activity = factories["federation.Activity"]()
impostor = factories["federation.Actor"]()
@ -469,6 +566,25 @@ def test_inbox_delete_audio(factories):
upload.refresh_from_db()
def test_inbox_delete_audio_channel(factories):
activity = factories["federation.Activity"]()
channel = factories["audio.Channel"]()
upload = factories["music.Upload"](track__artist=channel.artist)
payload = {
"type": "Delete",
"actor": channel.actor.fid,
"object": {"type": "Audio", "id": [upload.fid]},
}
routes.inbox_delete_audio(
payload,
context={"actor": channel.actor, "raise_exception": True, "activity": activity},
)
with pytest.raises(upload.__class__.DoesNotExist):
upload.refresh_from_db()
def test_inbox_delete_audio_impostor(factories):
activity = factories["federation.Activity"]()
impostor = factories["federation.Actor"]()

View File

@ -795,6 +795,17 @@ def test_activity_pub_album_serializer_to_ap(factories):
assert serializer.data == expected
def test_activity_pub_album_serializer_to_ap_channel_artist(factories):
channel = factories["audio.Channel"]()
album = factories["music.Album"](artist=channel.artist,)
serializer = serializers.AlbumSerializer(album)
assert serializer.data["artists"] == [
{"type": channel.actor.type, "id": channel.actor.fid}
]
def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
actor = factories["federation.Actor"]()
artist = factories["music.Artist"]()
@ -836,6 +847,30 @@ def test_activity_pub_album_serializer_from_ap_create(factories, faker, now):
]
def test_activity_pub_album_serializer_from_ap_create_channel_artist(
factories, faker, now
):
actor = factories["federation.Actor"]()
channel = factories["audio.Channel"]()
released = faker.date_object()
payload = {
"@context": jsonld.get_default_context(),
"type": "Album",
"id": "https://album.example",
"name": faker.sentence(),
"published": now.isoformat(),
"released": released.isoformat(),
"artists": [{"type": channel.actor.type, "id": channel.actor.fid}],
"attributedTo": actor.fid,
}
serializer = serializers.AlbumSerializer(data=payload)
assert serializer.is_valid(raise_exception=True) is True
album = serializer.save()
assert album.artist == channel.artist
def test_activity_pub_album_serializer_from_ap_update(factories, faker):
album = factories["music.Album"](attributed=True)
released = faker.date_object()
@ -1395,7 +1430,9 @@ def test_track_serializer_update_license(factories):
def test_channel_actor_serializer(factories):
channel = factories["audio.Channel"](
actor__attachment_icon=None, artist__with_cover=True
actor__attachment_icon=None,
artist__with_cover=True,
artist__set_tags=["punk", "rock"],
)
serializer = serializers.ActorSerializer(channel.actor)
@ -1418,6 +1455,164 @@ def test_channel_actor_serializer(factories):
}
assert serializer.data["url"] == expected_url
assert serializer.data["icon"] == expected_icon
assert serializer.data["attributedTo"] == channel.attributed_to.fid
assert serializer.data["category"] == channel.artist.content_category
assert serializer.data["tag"] == [
{"type": "Hashtag", "name": "#punk"},
{"type": "Hashtag", "name": "#rock"},
]
def test_channel_actor_serializer_from_ap_create(mocker, factories):
domain = factories["federation.Domain"](name="test.pod")
attributed_to = factories["federation.Actor"](domain=domain)
get_actor = mocker.patch.object(actors, "get_actor", return_value=attributed_to)
actor_data = {
"@context": jsonld.get_default_context(),
"followers": "https://test.pod/federation/actors/mychannel/followers",
"preferredUsername": "mychannel",
"id": "https://test.pod/federation/actors/mychannel",
"endpoints": {"sharedInbox": "https://test.pod/federation/shared/inbox"},
"name": "mychannel",
"following": "https://test.pod/federation/actors/mychannel/following",
"outbox": "https://test.pod/federation/actors/mychannel/outbox",
"url": [
{
"mediaType": "text/html",
"href": "https://test.pod/channels/mychannel",
"type": "Link",
},
{
"mediaType": "application/rss+xml",
"href": "https://test.pod/api/v1/channels/mychannel/rss",
"type": "Link",
},
],
"type": "Person",
"category": "podcast",
"attributedTo": attributed_to.fid,
"manuallyApprovesFollowers": False,
"inbox": "https://test.pod/federation/actors/mychannel/inbox",
"icon": {
"mediaType": "image/jpeg",
"type": "Image",
"url": "https://test.pod/media/attachments/dd/ce/b2/nosmile.jpeg",
},
"summary": "<p>content</p>",
"publicKey": {
"owner": "https://test.pod/federation/actors/mychannel",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\n+KwIDAQAB\n-----END RSA PUBLIC KEY-----\n",
"id": "https://test.pod/federation/actors/mychannel#main-key",
},
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
}
serializer = serializers.ActorSerializer(data=actor_data)
assert serializer.is_valid(raise_exception=True) is True
actor = serializer.save()
get_actor.assert_called_once_with(actor_data["attributedTo"])
assert actor.preferred_username == actor_data["preferredUsername"]
assert actor.fid == actor_data["id"]
assert actor.name == actor_data["name"]
assert actor.type == actor_data["type"]
assert actor.public_key == actor_data["publicKey"]["publicKeyPem"]
assert actor.outbox_url == actor_data["outbox"]
assert actor.inbox_url == actor_data["inbox"]
assert actor.shared_inbox_url == actor_data["endpoints"]["sharedInbox"]
assert actor.channel.attributed_to == attributed_to
assert actor.channel.rss_url == actor_data["url"][1]["href"]
assert actor.channel.artist.attributed_to == attributed_to
assert actor.channel.artist.content_category == actor_data["category"]
assert actor.channel.artist.name == actor_data["name"]
assert actor.channel.artist.get_tags() == ["Indie", "Punk", "Rock"]
assert actor.channel.artist.description.text == actor_data["summary"]
assert actor.channel.artist.description.content_type == "text/html"
assert actor.channel.artist.attachment_cover.url == actor_data["icon"]["url"]
assert (
actor.channel.artist.attachment_cover.mimetype
== actor_data["icon"]["mediaType"]
)
assert actor.channel.library.fid is not None
assert actor.channel.library.actor == attributed_to
assert actor.channel.library.privacy_level == "everyone"
assert actor.channel.library.name == actor_data["name"]
def test_channel_actor_serializer_from_ap_update(mocker, factories):
domain = factories["federation.Domain"](name="test.pod")
attributed_to = factories["federation.Actor"](domain=domain)
actor = factories["federation.Actor"](domain=domain)
channel = factories["audio.Channel"](actor=actor, attributed_to=attributed_to)
get_actor = mocker.patch.object(actors, "get_actor", return_value=attributed_to)
library = channel.library
actor_data = {
"@context": jsonld.get_default_context(),
"followers": "https://test.pod/federation/actors/mychannel/followers",
"preferredUsername": "mychannel",
"id": actor.fid,
"endpoints": {"sharedInbox": "https://test.pod/federation/shared/inbox"},
"name": "mychannel",
"following": "https://test.pod/federation/actors/mychannel/following",
"outbox": "https://test.pod/federation/actors/mychannel/outbox",
"url": [
{
"mediaType": "text/html",
"href": "https://test.pod/channels/mychannel",
"type": "Link",
},
{
"mediaType": "application/rss+xml",
"href": "https://test.pod/api/v1/channels/mychannel/rss",
"type": "Link",
},
],
"type": "Person",
"category": "podcast",
"attributedTo": attributed_to.fid,
"manuallyApprovesFollowers": False,
"inbox": "https://test.pod/federation/actors/mychannel/inbox",
"icon": {
"mediaType": "image/jpeg",
"type": "Image",
"url": "https://test.pod/media/attachments/dd/ce/b2/nosmile.jpeg",
},
"summary": "<p>content</p>",
"publicKey": {
"owner": "https://test.pod/federation/actors/mychannel",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\n+KwIDAQAB\n-----END RSA PUBLIC KEY-----\n",
"id": "https://test.pod/federation/actors/mychannel#main-key",
},
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
}
serializer = serializers.ActorSerializer(data=actor_data)
assert serializer.is_valid(raise_exception=True) is True
serializer.save()
channel.refresh_from_db()
get_actor.assert_called_once_with(actor_data["attributedTo"])
assert channel.actor == actor
assert channel.attributed_to == attributed_to
assert channel.rss_url == actor_data["url"][1]["href"]
assert channel.artist.attributed_to == attributed_to
assert channel.artist.content_category == actor_data["category"]
assert channel.artist.name == actor_data["name"]
assert channel.artist.get_tags() == ["Indie", "Punk", "Rock"]
assert channel.artist.description.text == actor_data["summary"]
assert channel.artist.description.content_type == "text/html"
assert channel.artist.attachment_cover.url == actor_data["icon"]["url"]
assert channel.artist.attachment_cover.mimetype == actor_data["icon"]["mediaType"]
assert channel.library.actor == attributed_to
assert channel.library.privacy_level == library.privacy_level
assert channel.library.name == library.name
def test_channel_actor_outbox_serializer(factories):
@ -1449,12 +1644,21 @@ def test_channel_actor_outbox_serializer(factories):
def test_channel_upload_serializer(factories):
channel = factories["audio.Channel"](library__privacy_level="everyone")
content = factories["common.Content"]()
cover = factories["common.Attachment"]()
upload = factories["music.Upload"](
playable=True,
bitrate=543,
size=543,
duration=54,
library=channel.library,
import_status="finished",
track__set_tags=["Punk"],
track__attachment_cover=cover,
track__description=content,
track__disc_number=3,
track__position=12,
track__license="cc0-1.0",
track__copyright="Copyright something",
track__album__set_tags=["Rock"],
track__artist__set_tags=["Indie"],
)
@ -1463,25 +1667,38 @@ def test_channel_upload_serializer(factories):
"@context": jsonld.get_default_context(),
"type": "Audio",
"id": upload.fid,
"name": upload.track.full_name,
"name": upload.track.title,
"summary": "#Indie #Punk #Rock",
"attributedTo": channel.actor.fid,
"published": upload.creation_date.isoformat(),
"mediaType": "text/html",
"content": common_utils.render_html(content.text, content.content_type),
"to": "https://www.w3.org/ns/activitystreams#Public",
"position": upload.track.position,
"duration": upload.duration,
"album": upload.track.album.fid,
"disc": upload.track.disc_number,
"copyright": upload.track.copyright,
"license": upload.track.local_license["identifiers"][0],
"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),
"bitrate": upload.bitrate,
"size": upload.size,
},
],
"image": {
"type": "Image",
"url": upload.track.attachment_cover.download_url_original,
"mediaType": upload.track.attachment_cover.mimetype,
},
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
@ -1494,6 +1711,166 @@ def test_channel_upload_serializer(factories):
assert serializer.data == expected
def test_channel_upload_serializer_from_ap_create(factories, now):
channel = factories["audio.Channel"](library__privacy_level="everyone")
album = factories["music.Album"](artist=channel.artist)
payload = {
"@context": jsonld.get_default_context(),
"type": "Audio",
"id": "https://test.pod/uuid",
"name": "My test track",
"summary": "#Indie #Punk #Rock",
"attributedTo": channel.actor.fid,
"published": now.isoformat(),
"mediaType": "text/html",
"content": "<p>Hello</p>",
"duration": 543,
"position": 4,
"disc": 2,
"album": album.fid,
"to": "https://www.w3.org/ns/activitystreams#Public",
"copyright": "Copyright test",
"license": "http://creativecommons.org/publicdomain/zero/1.0/",
"url": [
{
"type": "Link",
"mediaType": "text/html",
"href": "https://test.pod/track",
},
{
"type": "Link",
"mediaType": "audio/mpeg",
"href": "https://test.pod/file.mp3",
"bitrate": 192000,
"size": 15492738,
},
],
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
"image": {
"type": "Image",
"mediaType": "image/jpeg",
"url": "https://image.example/image.png",
},
}
serializer = serializers.ChannelUploadSerializer(
data=payload, context={"channel": channel}
)
assert serializer.is_valid(raise_exception=True) is True
upload = serializer.save(channel=channel)
assert upload.library == channel.library
assert upload.import_status == "finished"
assert upload.creation_date == now
assert upload.fid == payload["id"]
assert upload.source == payload["url"][1]["href"]
assert upload.mimetype == payload["url"][1]["mediaType"]
assert upload.size == payload["url"][1]["size"]
assert upload.bitrate == payload["url"][1]["bitrate"]
assert upload.duration == payload["duration"]
assert upload.track.artist == channel.artist
assert upload.track.position == payload["position"]
assert upload.track.disc_number == payload["disc"]
assert upload.track.attributed_to == channel.attributed_to
assert upload.track.title == payload["name"]
assert upload.track.creation_date == now
assert upload.track.description.content_type == payload["mediaType"]
assert upload.track.description.text == payload["content"]
assert upload.track.fid == payload["id"]
assert upload.track.license.pk == "cc0-1.0"
assert upload.track.copyright == payload["copyright"]
assert upload.track.get_tags() == ["Indie", "Punk", "Rock"]
assert upload.track.attachment_cover.mimetype == payload["image"]["mediaType"]
assert upload.track.attachment_cover.url == payload["image"]["url"]
assert upload.track.album == album
def test_channel_upload_serializer_from_ap_update(factories, now):
channel = factories["audio.Channel"](library__privacy_level="everyone")
album = factories["music.Album"](artist=channel.artist)
upload = factories["music.Upload"](track__album=album, track__artist=channel.artist)
payload = {
"@context": jsonld.get_default_context(),
"type": "Audio",
"id": upload.fid,
"name": "Hello there",
"attributedTo": channel.actor.fid,
"published": now.isoformat(),
"mediaType": "text/html",
"content": "<p>Hello</p>",
"duration": 543,
"position": 4,
"disc": 2,
"album": album.fid,
"to": "https://www.w3.org/ns/activitystreams#Public",
"copyright": "Copyright test",
"license": "http://creativecommons.org/publicdomain/zero/1.0/",
"url": [
{
"type": "Link",
"mediaType": "text/html",
"href": "https://test.pod/track",
},
{
"type": "Link",
"mediaType": "audio/mpeg",
"href": "https://test.pod/file.mp3",
"bitrate": 192000,
"size": 15492738,
},
],
"tag": [
{"type": "Hashtag", "name": "#Indie"},
{"type": "Hashtag", "name": "#Punk"},
{"type": "Hashtag", "name": "#Rock"},
],
"image": {
"type": "Image",
"mediaType": "image/jpeg",
"url": "https://image.example/image.png",
},
}
serializer = serializers.ChannelUploadSerializer(
data=payload, context={"channel": channel}
)
assert serializer.is_valid(raise_exception=True) is True
serializer.save(channel=channel)
upload.refresh_from_db()
assert upload.library == channel.library
assert upload.import_status == "finished"
assert upload.creation_date == now
assert upload.fid == payload["id"]
assert upload.source == payload["url"][1]["href"]
assert upload.mimetype == payload["url"][1]["mediaType"]
assert upload.size == payload["url"][1]["size"]
assert upload.bitrate == payload["url"][1]["bitrate"]
assert upload.duration == payload["duration"]
assert upload.track.artist == channel.artist
assert upload.track.position == payload["position"]
assert upload.track.disc_number == payload["disc"]
assert upload.track.attributed_to == channel.attributed_to
assert upload.track.title == payload["name"]
assert upload.track.creation_date == now
assert upload.track.description.content_type == payload["mediaType"]
assert upload.track.description.text == payload["content"]
assert upload.track.fid == payload["id"]
assert upload.track.license.pk == "cc0-1.0"
assert upload.track.copyright == payload["copyright"]
assert upload.track.get_tags() == ["Indie", "Punk", "Rock"]
assert upload.track.attachment_cover.mimetype == payload["image"]["mediaType"]
assert upload.track.attachment_cover.url == payload["image"]["url"]
assert upload.track.album == album
def test_channel_create_upload_serializer(factories):
channel = factories["audio.Channel"]()
upload = factories["music.Upload"](

View File

@ -491,6 +491,21 @@ def test_fetch_url(factory_name, serializer_class, factories, r_mock, mocker):
assert save.call_count == 1
def test_fetch_channel_actor_returns_channel(factories, r_mock):
obj = factories["audio.Channel"]()
fetch = factories["federation.Fetch"](url=obj.actor.fid)
payload = serializers.ActorSerializer(obj.actor).data
r_mock.get(obj.fid, json=payload)
tasks.fetch(fetch_id=fetch.pk)
fetch.refresh_from_db()
assert fetch.status == "finished"
assert fetch.object == obj
def test_fetch_honor_instance_policy_domain(factories):
domain = factories["moderation.InstancePolicy"](
block_all=True, for_domain=True

View File

@ -1407,7 +1407,8 @@ def test_channel_owner_can_create_album(factories, logged_in_api_client):
assert album.description.text == "hello world"
def test_channel_owner_can_delete_album(factories, logged_in_api_client):
def test_channel_owner_can_delete_album(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor)
album = factories["music.Album"](artist=channel.artist)
@ -1416,6 +1417,10 @@ def test_channel_owner_can_delete_album(factories, logged_in_api_client):
response = logged_in_api_client.delete(url)
assert response.status_code == 204
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": "Album"}}, context={"album": album}
)
with pytest.raises(album.DoesNotExist):
album.refresh_from_db()
@ -1452,15 +1457,22 @@ def test_other_user_cannot_delete_album(factories, logged_in_api_client):
album.refresh_from_db()
def test_channel_owner_can_delete_track(factories, logged_in_api_client):
def test_channel_owner_can_delete_track(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](attributed_to=actor)
track = factories["music.Track"](artist=channel.artist)
upload1 = factories["music.Upload"](track=track)
upload2 = factories["music.Upload"](track=track)
url = reverse("api:v1:tracks-detail", kwargs={"pk": track.pk})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
dispatch.assert_called_once_with(
{"type": "Delete", "object": {"type": "Audio"}},
context={"uploads": [upload1, upload2]},
)
with pytest.raises(track.DoesNotExist):
track.refresh_from_db()

View File

@ -7,9 +7,10 @@ from funkwhale_api.users import tasks
def test_delete_account(factories, mocker):
user = factories["users.User"]()
actor = user.create_actor()
factories["federation.Follow"](target=actor, approved=True)
library = factories["music.Library"](actor=actor)
unrelated_library = factories["music.Library"]()
dispatch = mocker.patch.object(routes.outbox, "dispatch")
dispatch = mocker.spy(routes.outbox, "dispatch")
tasks.delete_account(user_id=user.pk)
@ -30,3 +31,5 @@ def test_delete_account(factories, mocker):
assert actor.type == "Tombstone"
assert actor.name is None
assert actor.summary is None
# this activity shouldn't be deleted
assert actor.outbox_activities.filter(type="Delete").count() == 1

View File

@ -99,6 +99,8 @@ export default {
return {name: 'library.tracks.detail', params: {id: this.objInfo.id}}
case 'upload':
return {name: 'library.uploads.detail', params: {id: this.objInfo.uuid}}
case 'channel':
return {name: 'channels.detail', params: {id: this.objInfo.uuid}}
default:
break;
@ -147,7 +149,6 @@ export default {
return
}
if (this.standalone) {
console.log('HELLO')
this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
}
this.fetch = null

View File

@ -23,9 +23,9 @@
:translate-params="{count: totalTracks}">
%{ count } episode
</translate>
<template v-if="object.attributed_to.full_username === $store.state.auth.fullUsername || $store.getters['channels/isSubscribed'](object.uuid)">
· <translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" :translate-n="object.subscriptions_count" :translate-params="{count: object.subscriptions_count}">%{ count } subscriber</translate>
</template>
</template>
<template v-if="object.attributed_to.full_username === $store.state.auth.fullUsername || $store.getters['channels/isSubscribed'](object.uuid)">
· <translate translate-context="Content/Channel/Paragraph" translate-plural="%{ count } subscribers" :translate-n="object.subscriptions_count" :translate-params="{count: object.subscriptions_count}">%{ count } subscriber</translate>
</template>
<div class="ui hidden small divider"></div>
<a @click.stop.prevent="showSubscribeModal = true" class="ui icon small basic button">