Merge branch '170-funkwhale-federation' into 'develop'
See #170: Funkwhale federation See merge request funkwhale/funkwhale!1066
This commit is contained in:
commit
464010f046
|
@ -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"}],
|
||||
),
|
||||
}
|
||||
|
|
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)
|
||||
|
|
|
@ -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"](
|
||||
|
|
|
@ -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"),
|
||||
],
|
||||
)
|
||||
|
|
|
@ -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"]()
|
||||
|
|
|
@ -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"](
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in New Issue