2155 lines
76 KiB
Python
2155 lines
76 KiB
Python
import logging
|
|
import os
|
|
import urllib.parse
|
|
import uuid
|
|
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
from django.core.paginator import Paginator
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from rest_framework import serializers
|
|
|
|
from funkwhale_api.common import models as common_models
|
|
from funkwhale_api.common import utils as common_utils
|
|
from funkwhale_api.moderation import models as moderation_models
|
|
from funkwhale_api.moderation import serializers as moderation_serializers
|
|
from funkwhale_api.moderation import signals as moderation_signals
|
|
from funkwhale_api.music import licenses
|
|
from funkwhale_api.music import models as music_models
|
|
from funkwhale_api.music import tasks as music_tasks
|
|
from funkwhale_api.tags import models as tags_models
|
|
|
|
from . import activity, actors, contexts, jsonld, models, 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")
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def to_internal_value(self, v):
|
|
v = super().to_internal_value(v)
|
|
if v:
|
|
v = v[: self.truncate_length]
|
|
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()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", [])
|
|
self.allow_empty_mimetype = kwargs.pop("allow_empty_mimetype", False)
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["mediaType"].required = not self.allow_empty_mimetype
|
|
self.fields["mediaType"].allow_null = self.allow_empty_mimetype
|
|
|
|
def validate_mediaType(self, v):
|
|
if not self.allowed_mimetypes:
|
|
# no restrictions
|
|
return v
|
|
if self.allow_empty_mimetype and not v:
|
|
return None
|
|
|
|
if not is_mimetype(v, self.allowed_mimetypes):
|
|
raise serializers.ValidationError(
|
|
f"Invalid mimetype {v}. Allowed: {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)
|
|
url = serializers.URLField(max_length=500, required=False)
|
|
|
|
class Meta:
|
|
jsonld_mapping = {
|
|
"url": jsonld.first_id(contexts.AS.url),
|
|
"href": jsonld.first_id(contexts.AS.href),
|
|
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
|
}
|
|
|
|
def validate(self, data):
|
|
validated_data = super().validate(data)
|
|
if "url" not in validated_data:
|
|
try:
|
|
validated_data["url"] = validated_data["href"]
|
|
except KeyError:
|
|
if self.required:
|
|
raise serializers.ValidationError(
|
|
"You need to provide a url or href"
|
|
)
|
|
|
|
return validated_data
|
|
|
|
|
|
class URLSerializer(jsonld.JsonLdSerializer):
|
|
href = serializers.URLField(max_length=500)
|
|
mediaType = serializers.CharField(required=False)
|
|
|
|
class Meta:
|
|
jsonld_mapping = {
|
|
"href": jsonld.first_id(contexts.AS.href, aliases=[jsonld.raw("@id")]),
|
|
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
|
}
|
|
|
|
|
|
class EndpointsSerializer(jsonld.JsonLdSerializer):
|
|
sharedInbox = serializers.URLField(max_length=500, required=False)
|
|
|
|
class Meta:
|
|
jsonld_mapping = {"sharedInbox": jsonld.first_id(contexts.AS.sharedInbox)}
|
|
|
|
|
|
class PublicKeySerializer(jsonld.JsonLdSerializer):
|
|
publicKeyPem = serializers.CharField(trim_whitespace=False)
|
|
|
|
class Meta:
|
|
jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)}
|
|
|
|
|
|
def get_by_media_type(urls, media_type):
|
|
for url in urls:
|
|
if url.get("mediaType", "text/html") == 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)
|
|
inbox = serializers.URLField(max_length=500, required=False)
|
|
url = serializers.ListField(
|
|
child=URLSerializer(jsonld_expand=False), required=False, min_length=0
|
|
)
|
|
type = serializers.ChoiceField(
|
|
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
|
|
)
|
|
preferredUsername = serializers.CharField()
|
|
manuallyApprovesFollowers = serializers.BooleanField(
|
|
required=False, allow_null=True
|
|
)
|
|
name = serializers.CharField(
|
|
required=False, max_length=200, allow_blank=True, allow_null=True
|
|
)
|
|
summary = TruncatedCharField(
|
|
truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH,
|
|
required=False,
|
|
allow_null=True,
|
|
allow_blank=True,
|
|
)
|
|
followers = serializers.URLField(max_length=500, required=False)
|
|
following = serializers.URLField(max_length=500, required=False, allow_null=True)
|
|
publicKey = PublicKeySerializer(required=False)
|
|
endpoints = EndpointsSerializer(required=False)
|
|
icon = ImageSerializer(
|
|
allowed_mimetypes=["image/*"],
|
|
allow_null=True,
|
|
required=False,
|
|
allow_empty_mimetype=True,
|
|
)
|
|
attributedTo = serializers.URLField(max_length=500, required=False)
|
|
|
|
tags = serializers.ListField(min_length=0, required=False, allow_null=True)
|
|
|
|
def validate_tags(self, tags):
|
|
valid_tags = []
|
|
for tag in tags:
|
|
s = TagSerializer(data=tag)
|
|
if s.is_valid():
|
|
valid_tags.append(s.validated_data)
|
|
return valid_tags
|
|
|
|
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
|
|
# but used by tasks.py/fetch
|
|
model = models.Actor
|
|
|
|
jsonld_mapping = {
|
|
"outbox": jsonld.first_id(contexts.AS.outbox),
|
|
"inbox": jsonld.first_id(contexts.LDP.inbox),
|
|
"following": jsonld.first_id(contexts.AS.following),
|
|
"followers": jsonld.first_id(contexts.AS.followers),
|
|
"preferredUsername": jsonld.first_val(contexts.AS.preferredUsername),
|
|
"summary": jsonld.first_val(contexts.AS.summary),
|
|
"name": jsonld.first_val(contexts.AS.name),
|
|
"publicKey": jsonld.first_obj(contexts.SEC.publicKey),
|
|
"manuallyApprovesFollowers": jsonld.first_val(
|
|
contexts.AS.manuallyApprovesFollowers
|
|
),
|
|
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
|
"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,
|
|
"outbox": instance.outbox_url,
|
|
"inbox": instance.inbox_url,
|
|
"preferredUsername": instance.preferred_username,
|
|
"type": instance.type,
|
|
}
|
|
if instance.name:
|
|
ret["name"] = instance.name
|
|
if instance.followers_url:
|
|
ret["followers"] = instance.followers_url
|
|
if instance.following_url:
|
|
ret["following"] = instance.following_url
|
|
if instance.manually_approves_followers is not None:
|
|
ret["manuallyApprovesFollowers"] = instance.manually_approves_followers
|
|
|
|
if instance.summary_obj_id:
|
|
ret["summary"] = instance.summary_obj.rendered
|
|
urls = []
|
|
if instance.url:
|
|
urls.append(
|
|
{"type": "Link", "href": instance.url, "mediaType": "text/html"}
|
|
)
|
|
|
|
channel = instance.get_channel()
|
|
if channel:
|
|
ret["url"] = [
|
|
{
|
|
"type": "Link",
|
|
"href": instance.channel.get_absolute_url()
|
|
if instance.channel.artist.is_local
|
|
else instance.get_absolute_url(),
|
|
"mediaType": "text/html",
|
|
},
|
|
{
|
|
"type": "Link",
|
|
"href": instance.channel.get_rss_url(),
|
|
"mediaType": "application/rss+xml",
|
|
},
|
|
]
|
|
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"] = [
|
|
{
|
|
"type": "Link",
|
|
"href": instance.get_absolute_url(),
|
|
"mediaType": "text/html",
|
|
}
|
|
]
|
|
include_image(ret, instance.attachment_icon, "icon")
|
|
|
|
ret["@context"] = jsonld.get_default_context()
|
|
if instance.public_key:
|
|
ret["publicKey"] = {
|
|
"owner": instance.fid,
|
|
"publicKeyPem": instance.public_key,
|
|
"id": f"{instance.fid}#main-key",
|
|
}
|
|
ret["endpoints"] = {}
|
|
|
|
if instance.shared_inbox_url:
|
|
ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
|
|
return ret
|
|
|
|
def prepare_missing_fields(self):
|
|
kwargs = {
|
|
"fid": self.validated_data["id"],
|
|
"outbox_url": self.validated_data.get("outbox"),
|
|
"inbox_url": self.validated_data.get("inbox"),
|
|
"following_url": self.validated_data.get("following"),
|
|
"followers_url": self.validated_data.get("followers"),
|
|
"type": self.validated_data["type"],
|
|
"name": self.validated_data.get("name"),
|
|
"preferred_username": self.validated_data["preferredUsername"],
|
|
}
|
|
url = get_by_media_type(self.validated_data.get("url", []), "text/html")
|
|
if url:
|
|
kwargs["url"] = url["href"]
|
|
|
|
maf = self.validated_data.get("manuallyApprovesFollowers")
|
|
if maf is not None:
|
|
kwargs["manually_approves_followers"] = maf
|
|
domain = urllib.parse.urlparse(kwargs["fid"]).netloc
|
|
domain, domain_created = models.Domain.objects.get_or_create(pk=domain)
|
|
if domain_created and not domain.is_local:
|
|
from . import tasks
|
|
|
|
# first time we see the domain, we trigger nodeinfo fetching
|
|
tasks.update_domain_nodeinfo(domain_name=domain.name)
|
|
|
|
kwargs["domain"] = domain
|
|
for endpoint, url in self.validated_data.get("endpoints", {}).items():
|
|
if endpoint == "sharedInbox":
|
|
kwargs["shared_inbox_url"] = url
|
|
break
|
|
try:
|
|
kwargs["public_key"] = self.validated_data["publicKey"]["publicKeyPem"]
|
|
except KeyError:
|
|
pass
|
|
return kwargs
|
|
|
|
def validate_type(self, v):
|
|
return v.split("#")[-1]
|
|
|
|
def build(self):
|
|
d = self.prepare_missing_fields()
|
|
return models.Actor(**d)
|
|
|
|
def save(self, **kwargs):
|
|
d = self.prepare_missing_fields()
|
|
d.update(kwargs)
|
|
actor = models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0]
|
|
common_utils.attach_content(
|
|
actor, "summary_obj", self.validated_data["summary"]
|
|
)
|
|
if "icon" in self.validated_data:
|
|
new_value = self.validated_data["icon"]
|
|
common_utils.attach_file(
|
|
actor,
|
|
"attachment_icon",
|
|
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
|
|
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", actor.fid)
|
|
if rss_url:
|
|
# 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):
|
|
validated_data = super().validate(data)
|
|
if "summary" in data:
|
|
validated_data["summary"] = {
|
|
"content_type": "text/html",
|
|
"text": data["summary"],
|
|
}
|
|
else:
|
|
validated_data["summary"] = None
|
|
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
|
|
fields = [
|
|
"fid",
|
|
"url",
|
|
"creation_date",
|
|
"summary",
|
|
"preferred_username",
|
|
"name",
|
|
"last_fetch_date",
|
|
"domain",
|
|
"type",
|
|
"manually_approves_followers",
|
|
"full_username",
|
|
"is_local",
|
|
]
|
|
|
|
|
|
class BaseActivitySerializer(serializers.Serializer):
|
|
id = serializers.URLField(max_length=500, required=False)
|
|
type = serializers.CharField(max_length=100)
|
|
actor = serializers.URLField(max_length=500)
|
|
object = serializers.JSONField(required=False, allow_null=True)
|
|
|
|
def validate_actor(self, v):
|
|
expected = self.context.get("actor")
|
|
if expected and expected.fid != v:
|
|
raise serializers.ValidationError("Invalid actor")
|
|
if expected:
|
|
# avoid a DB lookup
|
|
return expected
|
|
try:
|
|
return models.Actor.objects.get(fid=v)
|
|
except models.Actor.DoesNotExist:
|
|
raise serializers.ValidationError("Actor not found")
|
|
|
|
def create(self, validated_data):
|
|
return models.Activity.objects.create(
|
|
fid=validated_data.get("id"),
|
|
actor=validated_data["actor"],
|
|
payload=self.initial_data,
|
|
type=validated_data["type"],
|
|
)
|
|
|
|
def validate(self, data):
|
|
self.validate_recipients(data, self.initial_data)
|
|
return super().validate(data)
|
|
|
|
def validate_recipients(self, data, payload):
|
|
"""
|
|
Ensure we have at least a to/cc field with valid actors
|
|
"""
|
|
data["to"] = payload.get("to", [])
|
|
data["cc"] = payload.get("cc", [])
|
|
|
|
if (
|
|
not data["to"]
|
|
and data.get("type") in ["Follow", "Accept"]
|
|
and data.get("object")
|
|
):
|
|
# there isn't always a to field for Accept/Follow
|
|
# in their follow activity, so we consider the recipient
|
|
# to be the follow object
|
|
if data["type"] == "Follow":
|
|
data["to"].append(str(data.get("object")))
|
|
else:
|
|
data["to"].append(data.get("object", {}).get("actor"))
|
|
|
|
if not data["to"] and not data["cc"] and not self.context.get("recipients"):
|
|
raise serializers.ValidationError(
|
|
"We cannot handle an activity with no recipient"
|
|
)
|
|
|
|
|
|
class FollowSerializer(serializers.Serializer):
|
|
id = serializers.URLField(max_length=500)
|
|
object = serializers.URLField(max_length=500)
|
|
actor = serializers.URLField(max_length=500)
|
|
type = serializers.ChoiceField(choices=["Follow"])
|
|
|
|
def validate_object(self, v):
|
|
expected = self.context.get("follow_target")
|
|
if self.parent:
|
|
# it's probably an accept, so everything is inverted, the actor
|
|
# the recipient does not matter
|
|
recipient = None
|
|
else:
|
|
recipient = self.context.get("recipient")
|
|
if expected and expected.fid != v:
|
|
raise serializers.ValidationError("Invalid target")
|
|
try:
|
|
obj = models.Actor.objects.get(fid=v)
|
|
if recipient and recipient.fid != obj.fid:
|
|
raise serializers.ValidationError("Invalid target")
|
|
return obj
|
|
except models.Actor.DoesNotExist:
|
|
pass
|
|
try:
|
|
qs = music_models.Library.objects.filter(fid=v)
|
|
if recipient:
|
|
qs = qs.filter(actor=recipient)
|
|
return qs.get()
|
|
except music_models.Library.DoesNotExist:
|
|
pass
|
|
|
|
raise serializers.ValidationError("Target not found")
|
|
|
|
def validate_actor(self, v):
|
|
expected = self.context.get("follow_actor")
|
|
if expected and expected.fid != v:
|
|
raise serializers.ValidationError("Invalid actor")
|
|
try:
|
|
return models.Actor.objects.get(fid=v)
|
|
except models.Actor.DoesNotExist:
|
|
raise serializers.ValidationError("Actor not found")
|
|
|
|
def save(self, **kwargs):
|
|
target = self.validated_data["object"]
|
|
|
|
if target._meta.label == "music.Library":
|
|
follow_class = models.LibraryFollow
|
|
else:
|
|
follow_class = models.Follow
|
|
defaults = kwargs
|
|
defaults["fid"] = self.validated_data["id"]
|
|
approved = kwargs.pop("approved", None)
|
|
follow, created = follow_class.objects.update_or_create(
|
|
actor=self.validated_data["actor"],
|
|
target=self.validated_data["object"],
|
|
defaults=defaults,
|
|
)
|
|
if not created:
|
|
# We likely received a new follow when we had an existing one in database
|
|
# this can happen when two instances are out of sync, e.g because some
|
|
# messages are not delivered properly. In this case, we don't change
|
|
# the follow approved status and return the follow as is.
|
|
# We set a new UUID to ensure the follow urls are updated properly
|
|
# cf #830
|
|
follow.uuid = uuid.uuid4()
|
|
follow.save(update_fields=["uuid"])
|
|
return follow
|
|
|
|
# it's a brand new follow, we use the approved value stored earlier
|
|
if approved != follow.approved:
|
|
follow.approved = approved
|
|
follow.save(update_fields=["approved"])
|
|
|
|
return follow
|
|
|
|
def to_representation(self, instance):
|
|
return {
|
|
"@context": jsonld.get_default_context(),
|
|
"actor": instance.actor.fid,
|
|
"id": instance.get_federation_id(),
|
|
"object": instance.target.fid,
|
|
"type": "Follow",
|
|
}
|
|
|
|
|
|
class APIFollowSerializer(serializers.ModelSerializer):
|
|
actor = APIActorSerializer()
|
|
target = APIActorSerializer()
|
|
|
|
class Meta:
|
|
model = models.Follow
|
|
fields = [
|
|
"uuid",
|
|
"id",
|
|
"approved",
|
|
"creation_date",
|
|
"modification_date",
|
|
"actor",
|
|
"target",
|
|
]
|
|
|
|
|
|
class FollowActionSerializer(serializers.Serializer):
|
|
id = serializers.URLField(max_length=500, required=False)
|
|
actor = serializers.URLField(max_length=500)
|
|
object = FollowSerializer()
|
|
|
|
def validate_actor(self, v):
|
|
expected = self.context.get("actor")
|
|
if expected and expected.fid != v:
|
|
raise serializers.ValidationError("Invalid actor")
|
|
try:
|
|
return models.Actor.objects.get(fid=v)
|
|
except models.Actor.DoesNotExist:
|
|
raise serializers.ValidationError("Actor not found")
|
|
|
|
def validate(self, validated_data):
|
|
# we ensure the accept actor actually match the follow target / library owner
|
|
target = validated_data["object"]["object"]
|
|
|
|
if target._meta.label == "music.Library":
|
|
expected = target.actor
|
|
follow_class = models.LibraryFollow
|
|
else:
|
|
expected = target
|
|
follow_class = models.Follow
|
|
if validated_data["actor"] != expected:
|
|
raise serializers.ValidationError("Actor mismatch")
|
|
try:
|
|
validated_data["follow"] = (
|
|
follow_class.objects.filter(
|
|
target=target, actor=validated_data["object"]["actor"]
|
|
)
|
|
.select_related()
|
|
.get()
|
|
)
|
|
except follow_class.DoesNotExist:
|
|
raise serializers.ValidationError(f"No follow to {self.action_type}")
|
|
return validated_data
|
|
|
|
def to_representation(self, instance):
|
|
if instance.target._meta.label == "music.Library":
|
|
actor = instance.target.actor
|
|
else:
|
|
actor = instance.target
|
|
|
|
return {
|
|
"@context": jsonld.get_default_context(),
|
|
"id": instance.get_federation_id() + f"/{self.action_type}",
|
|
"type": self.action_type.title(),
|
|
"actor": actor.fid,
|
|
"object": FollowSerializer(instance).data,
|
|
}
|
|
|
|
|
|
class AcceptFollowSerializer(FollowActionSerializer):
|
|
type = serializers.ChoiceField(choices=["Accept"])
|
|
action_type = "accept"
|
|
|
|
def save(self):
|
|
follow = self.validated_data["follow"]
|
|
follow.approved = True
|
|
follow.save()
|
|
if follow.target._meta.label == "music.Library":
|
|
follow.target.schedule_scan(actor=follow.actor)
|
|
return follow
|
|
|
|
|
|
class RejectFollowSerializer(FollowActionSerializer):
|
|
type = serializers.ChoiceField(choices=["Reject"])
|
|
action_type = "reject"
|
|
|
|
def save(self):
|
|
follow = self.validated_data["follow"]
|
|
follow.approved = False
|
|
follow.save()
|
|
return follow
|
|
|
|
|
|
class UndoFollowSerializer(serializers.Serializer):
|
|
id = serializers.URLField(max_length=500)
|
|
actor = serializers.URLField(max_length=500)
|
|
object = FollowSerializer()
|
|
type = serializers.ChoiceField(choices=["Undo"])
|
|
|
|
def validate_actor(self, v):
|
|
expected = self.context.get("actor")
|
|
|
|
if expected and expected.fid != v:
|
|
raise serializers.ValidationError("Invalid actor")
|
|
try:
|
|
return models.Actor.objects.get(fid=v)
|
|
except models.Actor.DoesNotExist:
|
|
raise serializers.ValidationError("Actor not found")
|
|
|
|
def validate(self, validated_data):
|
|
# we ensure the accept actor actually match the follow actor
|
|
if validated_data["actor"] != validated_data["object"]["actor"]:
|
|
raise serializers.ValidationError("Actor mismatch")
|
|
|
|
target = validated_data["object"]["object"]
|
|
|
|
if target._meta.label == "music.Library":
|
|
follow_class = models.LibraryFollow
|
|
else:
|
|
follow_class = models.Follow
|
|
|
|
try:
|
|
validated_data["follow"] = follow_class.objects.filter(
|
|
actor=validated_data["actor"], target=target
|
|
).get()
|
|
except follow_class.DoesNotExist:
|
|
raise serializers.ValidationError("No follow to remove")
|
|
return validated_data
|
|
|
|
def to_representation(self, instance):
|
|
return {
|
|
"@context": jsonld.get_default_context(),
|
|
"id": instance.get_federation_id() + "/undo",
|
|
"type": "Undo",
|
|
"actor": instance.actor.fid,
|
|
"object": FollowSerializer(instance).data,
|
|
}
|
|
|
|
def save(self):
|
|
return self.validated_data["follow"].delete()
|
|
|
|
|
|
class ActorWebfingerSerializer(serializers.Serializer):
|
|
subject = serializers.CharField()
|
|
aliases = serializers.ListField(child=serializers.URLField(max_length=500))
|
|
links = serializers.ListField()
|
|
actor_url = serializers.URLField(max_length=500, required=False)
|
|
|
|
def validate(self, validated_data):
|
|
validated_data["actor_url"] = None
|
|
for l in validated_data["links"]:
|
|
try:
|
|
if not l["rel"] == "self":
|
|
continue
|
|
if not l["type"] == "application/activity+json":
|
|
continue
|
|
validated_data["actor_url"] = l["href"]
|
|
break
|
|
except KeyError:
|
|
pass
|
|
if validated_data["actor_url"] is None:
|
|
raise serializers.ValidationError("No valid actor url found")
|
|
return validated_data
|
|
|
|
def to_representation(self, instance):
|
|
data = {}
|
|
data["subject"] = f"acct:{instance.webfinger_subject}"
|
|
data["links"] = [
|
|
{"rel": "self", "href": instance.fid, "type": "application/activity+json"}
|
|
]
|
|
data["aliases"] = [instance.fid]
|
|
return data
|
|
|
|
|
|
class ActivitySerializer(serializers.Serializer):
|
|
actor = serializers.URLField(max_length=500)
|
|
id = serializers.URLField(max_length=500, required=False)
|
|
type = serializers.ChoiceField(choices=[(c, c) for c in activity.ACTIVITY_TYPES])
|
|
object = serializers.JSONField(required=False)
|
|
target = serializers.JSONField(required=False)
|
|
|
|
def validate_object(self, value):
|
|
try:
|
|
type = value["type"]
|
|
except KeyError:
|
|
raise serializers.ValidationError("Missing object type")
|
|
except TypeError:
|
|
# probably a URL
|
|
return value
|
|
try:
|
|
object_serializer = OBJECT_SERIALIZERS[type]
|
|
except KeyError:
|
|
raise serializers.ValidationError(f"Unsupported type {type}")
|
|
|
|
serializer = object_serializer(data=value)
|
|
serializer.is_valid(raise_exception=True)
|
|
return serializer.data
|
|
|
|
def validate_actor(self, value):
|
|
request_actor = self.context.get("actor")
|
|
if request_actor and request_actor.fid != value:
|
|
raise serializers.ValidationError(
|
|
"The actor making the request do not match" " the activity actor"
|
|
)
|
|
return value
|
|
|
|
def to_representation(self, conf):
|
|
d = {}
|
|
d.update(conf)
|
|
|
|
if self.context.get("include_ap_context", True):
|
|
d["@context"] = jsonld.get_default_context()
|
|
return d
|
|
|
|
|
|
class ObjectSerializer(serializers.Serializer):
|
|
id = serializers.URLField(max_length=500)
|
|
url = serializers.URLField(max_length=500, required=False, allow_null=True)
|
|
type = serializers.ChoiceField(choices=[(c, c) for c in activity.OBJECT_TYPES])
|
|
content = serializers.CharField(required=False, allow_null=True)
|
|
summary = serializers.CharField(required=False, allow_null=True)
|
|
name = serializers.CharField(required=False, allow_null=True)
|
|
published = serializers.DateTimeField(required=False, allow_null=True)
|
|
updated = serializers.DateTimeField(required=False, allow_null=True)
|
|
to = serializers.ListField(
|
|
child=serializers.URLField(max_length=500), required=False, allow_null=True
|
|
)
|
|
cc = serializers.ListField(
|
|
child=serializers.URLField(max_length=500), required=False, allow_null=True
|
|
)
|
|
bto = serializers.ListField(
|
|
child=serializers.URLField(max_length=500), required=False, allow_null=True
|
|
)
|
|
bcc = serializers.ListField(
|
|
child=serializers.URLField(max_length=500), required=False, allow_null=True
|
|
)
|
|
|
|
|
|
OBJECT_SERIALIZERS = {t: ObjectSerializer for t in activity.OBJECT_TYPES}
|
|
|
|
|
|
def get_additional_fields(data):
|
|
UNSET = object()
|
|
additional_fields = {}
|
|
for field in ["name", "summary"]:
|
|
v = data.get(field, UNSET)
|
|
if v == UNSET:
|
|
continue
|
|
additional_fields[field] = v
|
|
|
|
return additional_fields
|
|
|
|
|
|
PAGINATED_COLLECTION_JSONLD_MAPPING = {
|
|
"totalItems": jsonld.first_val(contexts.AS.totalItems),
|
|
"first": jsonld.first_id(contexts.AS.first),
|
|
"last": jsonld.first_id(contexts.AS.last),
|
|
"partOf": jsonld.first_id(contexts.AS.partOf),
|
|
}
|
|
|
|
|
|
class PaginatedCollectionSerializer(jsonld.JsonLdSerializer):
|
|
type = serializers.ChoiceField(
|
|
choices=[contexts.AS.Collection, contexts.AS.OrderedCollection]
|
|
)
|
|
totalItems = serializers.IntegerField(min_value=0)
|
|
id = serializers.URLField(max_length=500)
|
|
first = serializers.URLField(max_length=500)
|
|
last = serializers.URLField(max_length=500)
|
|
|
|
class Meta:
|
|
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
|
|
|
|
def to_representation(self, conf):
|
|
paginator = Paginator(conf["items"], conf.get("page_size", 20))
|
|
first = common_utils.set_query_parameter(conf["id"], page=1)
|
|
current = first
|
|
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
|
|
d = {
|
|
"id": conf["id"],
|
|
"attributedTo": conf["actor"].fid,
|
|
"totalItems": paginator.count,
|
|
"type": conf.get("type", "Collection"),
|
|
"current": current,
|
|
"first": first,
|
|
"last": last,
|
|
}
|
|
d.update(get_additional_fields(conf))
|
|
if self.context.get("include_ap_context", True):
|
|
d["@context"] = jsonld.get_default_context()
|
|
return d
|
|
|
|
|
|
class LibrarySerializer(PaginatedCollectionSerializer):
|
|
type = serializers.ChoiceField(
|
|
choices=[contexts.AS.Collection, contexts.FW.Library]
|
|
)
|
|
actor = serializers.URLField(max_length=500, required=False)
|
|
attributedTo = serializers.URLField(max_length=500, required=False)
|
|
name = serializers.CharField()
|
|
summary = serializers.CharField(allow_blank=True, allow_null=True, required=False)
|
|
followers = serializers.URLField(max_length=500)
|
|
audience = serializers.ChoiceField(
|
|
choices=["", "./", None, "https://www.w3.org/ns/activitystreams#Public"],
|
|
required=False,
|
|
allow_null=True,
|
|
allow_blank=True,
|
|
)
|
|
|
|
class Meta:
|
|
# not strictly necessary because it's not a model serializer
|
|
# but used by tasks.py/fetch
|
|
model = music_models.Library
|
|
|
|
jsonld_mapping = common_utils.concat_dicts(
|
|
PAGINATED_COLLECTION_JSONLD_MAPPING,
|
|
{
|
|
"name": jsonld.first_val(contexts.AS.name),
|
|
"summary": jsonld.first_val(contexts.AS.summary),
|
|
"audience": jsonld.first_id(contexts.AS.audience),
|
|
"followers": jsonld.first_id(contexts.AS.followers),
|
|
"actor": jsonld.first_id(contexts.AS.actor),
|
|
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
|
|
},
|
|
)
|
|
|
|
def validate(self, validated_data):
|
|
d = super().validate(validated_data)
|
|
actor = d.get("actor")
|
|
attributed_to = d.get("attributedTo")
|
|
if not actor and not attributed_to:
|
|
raise serializers.ValidationError(
|
|
"You need to provide at least actor or attributedTo"
|
|
)
|
|
|
|
d["attributedTo"] = attributed_to or actor
|
|
return d
|
|
|
|
def to_representation(self, library):
|
|
conf = {
|
|
"id": library.fid,
|
|
"name": library.name,
|
|
"summary": library.description,
|
|
"page_size": 100,
|
|
"attributedTo": library.actor,
|
|
"actor": library.actor,
|
|
"items": library.uploads.for_federation(),
|
|
"type": "Library",
|
|
}
|
|
r = super().to_representation(conf)
|
|
r["audience"] = (
|
|
contexts.AS.Public if library.privacy_level == "everyone" else ""
|
|
)
|
|
r["followers"] = library.followers_url
|
|
return r
|
|
|
|
def create(self, validated_data):
|
|
if self.instance:
|
|
actor = self.instance.actor
|
|
else:
|
|
actor = utils.retrieve_ap_object(
|
|
validated_data["attributedTo"],
|
|
actor=self.context.get("fetch_actor"),
|
|
queryset=models.Actor,
|
|
serializer_class=ActorSerializer,
|
|
)
|
|
privacy = {"": "me", "./": "me", None: "me", contexts.AS.Public: "everyone"}
|
|
library, created = music_models.Library.objects.update_or_create(
|
|
fid=validated_data["id"],
|
|
actor=actor,
|
|
defaults={
|
|
"uploads_count": validated_data["totalItems"],
|
|
"name": validated_data["name"],
|
|
"description": validated_data.get("summary"),
|
|
"followers_url": validated_data["followers"],
|
|
"privacy_level": privacy[validated_data["audience"]],
|
|
},
|
|
)
|
|
return library
|
|
|
|
def update(self, instance, validated_data):
|
|
return self.create(validated_data)
|
|
|
|
|
|
class CollectionPageSerializer(jsonld.JsonLdSerializer):
|
|
type = serializers.ChoiceField(choices=[contexts.AS.CollectionPage])
|
|
totalItems = serializers.IntegerField(min_value=0)
|
|
items = serializers.ListField()
|
|
actor = serializers.URLField(max_length=500, required=False)
|
|
attributedTo = serializers.URLField(max_length=500, required=False)
|
|
id = serializers.URLField(max_length=500)
|
|
first = serializers.URLField(max_length=500)
|
|
last = serializers.URLField(max_length=500)
|
|
next = serializers.URLField(max_length=500, required=False)
|
|
prev = serializers.URLField(max_length=500, required=False)
|
|
partOf = serializers.URLField(max_length=500)
|
|
|
|
class Meta:
|
|
jsonld_mapping = {
|
|
"totalItems": jsonld.first_val(contexts.AS.totalItems),
|
|
"items": jsonld.raw(contexts.AS.items),
|
|
"actor": jsonld.first_id(contexts.AS.actor),
|
|
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
|
|
"first": jsonld.first_id(contexts.AS.first),
|
|
"last": jsonld.first_id(contexts.AS.last),
|
|
"next": jsonld.first_id(contexts.AS.next),
|
|
"prev": jsonld.first_id(contexts.AS.prev),
|
|
"partOf": jsonld.first_id(contexts.AS.partOf),
|
|
}
|
|
|
|
def validate_items(self, v):
|
|
item_serializer = self.context.get("item_serializer")
|
|
if not item_serializer:
|
|
return v
|
|
raw_items = [item_serializer(data=i, context=self.context) for i in v]
|
|
valid_items = []
|
|
for i in raw_items:
|
|
try:
|
|
i.is_valid(raise_exception=True)
|
|
valid_items.append(i)
|
|
except serializers.ValidationError:
|
|
logger.debug("Invalid item %s: %s", i.data, i.errors)
|
|
|
|
return valid_items
|
|
|
|
def to_representation(self, conf):
|
|
page = conf["page"]
|
|
first = common_utils.set_query_parameter(conf["id"], page=1)
|
|
last = common_utils.set_query_parameter(
|
|
conf["id"], page=page.paginator.num_pages
|
|
)
|
|
id = common_utils.set_query_parameter(conf["id"], page=page.number)
|
|
d = {
|
|
"id": id,
|
|
"partOf": conf["id"],
|
|
"totalItems": page.paginator.count,
|
|
"type": "CollectionPage",
|
|
"first": first,
|
|
"last": last,
|
|
"items": [
|
|
conf["item_serializer"](
|
|
i, context={"actor": conf["actor"], "include_ap_context": False}
|
|
).data
|
|
for i in page.object_list
|
|
],
|
|
}
|
|
if conf["actor"]:
|
|
d["attributedTo"] = conf["actor"].fid
|
|
|
|
if page.has_previous():
|
|
d["prev"] = common_utils.set_query_parameter(
|
|
conf["id"], page=page.previous_page_number()
|
|
)
|
|
|
|
if page.has_next():
|
|
d["next"] = common_utils.set_query_parameter(
|
|
conf["id"], page=page.next_page_number()
|
|
)
|
|
d.update(get_additional_fields(conf))
|
|
if self.context.get("include_ap_context", True):
|
|
d["@context"] = jsonld.get_default_context()
|
|
return d
|
|
|
|
|
|
MUSIC_ENTITY_JSONLD_MAPPING = {
|
|
"name": jsonld.first_val(contexts.AS.name),
|
|
"published": jsonld.first_val(contexts.AS.published),
|
|
"musicbrainzId": jsonld.first_val(contexts.FW.musicbrainzId),
|
|
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
|
|
"tags": jsonld.raw(contexts.AS.tag),
|
|
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
|
"content": jsonld.first_val(contexts.AS.content),
|
|
}
|
|
|
|
|
|
def repr_tag(tag_name):
|
|
return {"type": "Hashtag", "name": f"#{tag_name}"}
|
|
|
|
|
|
def include_content(repr, content_obj):
|
|
if not content_obj:
|
|
return
|
|
|
|
repr["content"] = common_utils.render_html(
|
|
content_obj.text, content_obj.content_type
|
|
)
|
|
repr["mediaType"] = "text/html"
|
|
|
|
|
|
def include_image(repr, attachment, field="image"):
|
|
if attachment:
|
|
repr[field] = {
|
|
"type": "Image",
|
|
"url": attachment.download_url_original,
|
|
"mediaType": attachment.mimetype or "image/jpeg",
|
|
}
|
|
else:
|
|
repr[field] = None
|
|
|
|
|
|
class MusicEntitySerializer(jsonld.JsonLdSerializer):
|
|
id = serializers.URLField(max_length=500)
|
|
published = serializers.DateTimeField()
|
|
musicbrainzId = serializers.UUIDField(allow_null=True, required=False)
|
|
name = serializers.CharField(max_length=1000)
|
|
attributedTo = serializers.URLField(max_length=500, allow_null=True, required=False)
|
|
updateable_fields = []
|
|
tags = serializers.ListField(
|
|
child=TagSerializer(), min_length=0, required=False, allow_null=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,
|
|
)
|
|
|
|
def update(self, instance, validated_data):
|
|
return self.update_or_create(validated_data)
|
|
|
|
@transaction.atomic
|
|
def update_or_create(self, validated_data):
|
|
instance = self.instance or self.Meta.model(fid=validated_data["id"])
|
|
creating = instance.pk is None
|
|
attributed_to_fid = validated_data.get("attributedTo")
|
|
if attributed_to_fid:
|
|
validated_data["attributedTo"] = actors.get_actor(attributed_to_fid)
|
|
updated_fields = common_utils.get_updated_fields(
|
|
self.updateable_fields, validated_data, instance
|
|
)
|
|
updated_fields = self.validate_updated_data(instance, updated_fields)
|
|
|
|
set_ac = False
|
|
if "artist_credit" in updated_fields:
|
|
artist_credit = updated_fields.pop("artist_credit")
|
|
set_ac = True
|
|
|
|
if creating:
|
|
instance, created = self.Meta.model.objects.get_or_create(
|
|
fid=validated_data["id"], defaults=updated_fields
|
|
)
|
|
if set_ac:
|
|
instance.artist_credit.set(artist_credit)
|
|
else:
|
|
obj = music_tasks.update_library_entity(instance, updated_fields)
|
|
if set_ac:
|
|
obj.artist_credit.set(artist_credit)
|
|
|
|
tags = [t["name"] for t in validated_data.get("tags", []) or []]
|
|
tags_models.set_tags(instance, *tags)
|
|
common_utils.attach_content(
|
|
instance, "description", validated_data.get("description")
|
|
)
|
|
return instance
|
|
|
|
def get_tags_repr(self, instance):
|
|
return tag_list(instance.tagged_items.all())
|
|
|
|
def validate_updated_data(self, instance, validated_data):
|
|
try:
|
|
attachment_cover = validated_data.pop("attachment_cover")
|
|
except KeyError:
|
|
return validated_data
|
|
|
|
if (
|
|
instance.attachment_cover
|
|
and instance.attachment_cover.url == attachment_cover["url"]
|
|
):
|
|
# we already have the proper attachment
|
|
return validated_data
|
|
# create the attachment by hand so it can be attached as the cover
|
|
validated_data["attachment_cover"] = common_models.Attachment.objects.create(
|
|
mimetype=attachment_cover.get("mediaType"),
|
|
url=attachment_cover["url"],
|
|
actor=instance.attributed_to,
|
|
)
|
|
return validated_data
|
|
|
|
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
|
|
|
|
|
|
class ArtistSerializer(MusicEntitySerializer):
|
|
image = ImageSerializer(
|
|
allowed_mimetypes=["image/*"],
|
|
allow_null=True,
|
|
required=False,
|
|
allow_empty_mimetype=True,
|
|
)
|
|
updateable_fields = [
|
|
("name", "name"),
|
|
("musicbrainzId", "mbid"),
|
|
("attributedTo", "attributed_to"),
|
|
("image", "attachment_cover"),
|
|
]
|
|
|
|
class Meta:
|
|
model = music_models.Artist
|
|
jsonld_mapping = common_utils.concat_dicts(
|
|
MUSIC_ENTITY_JSONLD_MAPPING,
|
|
{
|
|
"released": jsonld.first_val(contexts.FW.released),
|
|
"image": jsonld.first_obj(contexts.AS.image),
|
|
},
|
|
)
|
|
|
|
def to_representation(self, instance):
|
|
d = {
|
|
"type": "Artist",
|
|
"id": instance.fid,
|
|
"name": instance.name,
|
|
"published": instance.creation_date.isoformat(),
|
|
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
|
|
"attributedTo": instance.attributed_to.fid
|
|
if instance.attributed_to
|
|
else None,
|
|
"tag": self.get_tags_repr(instance),
|
|
}
|
|
include_content(d, instance.description)
|
|
include_image(d, instance.attachment_cover)
|
|
if self.context.get("include_ap_context", self.parent is None):
|
|
d["@context"] = jsonld.get_default_context()
|
|
return d
|
|
|
|
create = MusicEntitySerializer.update_or_create
|
|
|
|
|
|
class ArtistCreditSerializer(jsonld.JsonLdSerializer):
|
|
artist = ArtistSerializer()
|
|
joinphrase = serializers.CharField(
|
|
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
|
|
)
|
|
credit = serializers.CharField(
|
|
trim_whitespace=False, required=False, allow_null=True, allow_blank=True
|
|
)
|
|
published = serializers.DateTimeField()
|
|
id = serializers.URLField(max_length=500)
|
|
|
|
updateable_fields = [
|
|
("credit", "credit"),
|
|
("artist", "artist"),
|
|
("joinphrase", "joinphrase"),
|
|
]
|
|
|
|
class Meta:
|
|
model = music_models.ArtistCredit
|
|
jsonld_mapping = {
|
|
"artist": jsonld.first_obj(contexts.FW.artist),
|
|
"credit": jsonld.first_val(contexts.FW.credit),
|
|
"index": jsonld.first_val(contexts.FW.index),
|
|
"joinphrase": jsonld.first_val(contexts.FW.joinphrase),
|
|
"published": jsonld.first_val(contexts.AS.published),
|
|
}
|
|
|
|
def to_representation(self, instance):
|
|
data = {
|
|
"type": "ArtistCredit",
|
|
"id": instance.fid,
|
|
"artist": ArtistSerializer(
|
|
instance.artist, context={"include_ap_context": False}
|
|
).data,
|
|
"joinphrase": instance.joinphrase,
|
|
"credit": instance.credit,
|
|
"index": instance.index,
|
|
"published": instance.creation_date.isoformat(),
|
|
}
|
|
if self.context.get("include_ap_context", self.parent is None):
|
|
data["@context"] = jsonld.get_default_context()
|
|
return data
|
|
|
|
|
|
class AlbumSerializer(MusicEntitySerializer):
|
|
released = serializers.DateField(allow_null=True, required=False)
|
|
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
|
|
image = ImageSerializer(
|
|
allowed_mimetypes=["image/*"],
|
|
allow_null=True,
|
|
required=False,
|
|
allow_empty_mimetype=True,
|
|
)
|
|
updateable_fields = [
|
|
("name", "title"),
|
|
("image", "attachment_cover"),
|
|
("musicbrainzId", "mbid"),
|
|
("attributedTo", "attributed_to"),
|
|
("released", "release_date"),
|
|
("artist_credit", "artist_credit"),
|
|
]
|
|
|
|
class Meta:
|
|
model = music_models.Album
|
|
jsonld_mapping = common_utils.concat_dicts(
|
|
MUSIC_ENTITY_JSONLD_MAPPING,
|
|
{
|
|
"released": jsonld.first_val(contexts.FW.released),
|
|
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
|
|
"image": jsonld.first_obj(contexts.AS.image),
|
|
},
|
|
)
|
|
|
|
def to_representation(self, instance):
|
|
data = {
|
|
"type": "Album",
|
|
"id": instance.fid,
|
|
"name": instance.title,
|
|
"published": instance.creation_date.isoformat(),
|
|
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
|
|
"released": instance.release_date.isoformat()
|
|
if instance.release_date
|
|
else None,
|
|
"attributedTo": instance.attributed_to.fid
|
|
if instance.attributed_to
|
|
else None,
|
|
"tag": self.get_tags_repr(instance),
|
|
}
|
|
|
|
data["artist_credit"] = ArtistCreditSerializer(
|
|
instance.artist_credit.all(),
|
|
context={"include_ap_context": False},
|
|
many=True,
|
|
).data
|
|
include_content(data, instance.description)
|
|
if instance.attachment_cover:
|
|
include_image(data, instance.attachment_cover)
|
|
|
|
if self.context.get("include_ap_context", self.parent is None):
|
|
data["@context"] = jsonld.get_default_context()
|
|
return data
|
|
|
|
def validate(self, data):
|
|
validated_data = super().validate(data)
|
|
if not self.parent:
|
|
artist_credit_data = validated_data["artist_credit"]
|
|
if artist_credit_data[0]["artist"].get("type", "Artist") == "Artist":
|
|
acs = []
|
|
for ac in validated_data["artist_credit"]:
|
|
acs.append(
|
|
utils.retrieve_ap_object(
|
|
ac["id"],
|
|
actor=self.context.get("fetch_actor"),
|
|
queryset=music_models.ArtistCredit,
|
|
serializer_class=ArtistCreditSerializer,
|
|
)
|
|
)
|
|
validated_data["artist_credit"] = acs
|
|
else:
|
|
# we have an actor as an artist, so it's a channel
|
|
actor = actors.get_actor(artist_credit_data[0]["artist"]["id"])
|
|
validated_data["artist_credit"] = [{"artist": actor.channel.artist}]
|
|
|
|
return validated_data
|
|
|
|
create = MusicEntitySerializer.update_or_create
|
|
|
|
|
|
class TrackSerializer(MusicEntitySerializer):
|
|
position = serializers.IntegerField(min_value=0, allow_null=True, required=False)
|
|
disc = serializers.IntegerField(min_value=1, allow_null=True, required=False)
|
|
artist_credit = serializers.ListField(child=ArtistCreditSerializer(), min_length=1)
|
|
album = AlbumSerializer()
|
|
license = serializers.URLField(allow_null=True, required=False)
|
|
copyright = serializers.CharField(allow_null=True, required=False)
|
|
image = ImageSerializer(
|
|
allowed_mimetypes=["image/*"],
|
|
allow_null=True,
|
|
required=False,
|
|
allow_empty_mimetype=True,
|
|
)
|
|
|
|
updateable_fields = [
|
|
("name", "title"),
|
|
("musicbrainzId", "mbid"),
|
|
("attributedTo", "attributed_to"),
|
|
("disc", "disc_number"),
|
|
("position", "position"),
|
|
("copyright", "copyright"),
|
|
("license", "license"),
|
|
("image", "attachment_cover"),
|
|
]
|
|
|
|
class Meta:
|
|
model = music_models.Track
|
|
jsonld_mapping = common_utils.concat_dicts(
|
|
MUSIC_ENTITY_JSONLD_MAPPING,
|
|
{
|
|
"album": jsonld.first_obj(contexts.FW.album),
|
|
"artist_credit": jsonld.first_attr(contexts.FW.artist_credit, "@list"),
|
|
"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),
|
|
},
|
|
)
|
|
|
|
def to_representation(self, instance):
|
|
data = {
|
|
"type": "Track",
|
|
"id": instance.fid,
|
|
"name": instance.title,
|
|
"published": instance.creation_date.isoformat(),
|
|
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
|
|
"position": instance.position,
|
|
"disc": instance.disc_number,
|
|
"license": instance.local_license["identifiers"][0]
|
|
if instance.local_license
|
|
else None,
|
|
"copyright": instance.copyright if instance.copyright else None,
|
|
"artist_credit": ArtistCreditSerializer(
|
|
instance.artist_credit.all(),
|
|
context={"include_ap_context": False},
|
|
many=True,
|
|
).data,
|
|
"album": AlbumSerializer(
|
|
instance.album, context={"include_ap_context": False}
|
|
).data,
|
|
"attributedTo": instance.attributed_to.fid
|
|
if instance.attributed_to
|
|
else None,
|
|
"tag": self.get_tags_repr(instance),
|
|
}
|
|
include_content(data, instance.description)
|
|
include_image(data, instance.attachment_cover)
|
|
if self.context.get("include_ap_context", self.parent is None):
|
|
data["@context"] = jsonld.get_default_context()
|
|
return data
|
|
|
|
@transaction.atomic
|
|
def create(self, validated_data):
|
|
from funkwhale_api.music import tasks as music_tasks
|
|
|
|
references = {}
|
|
actors_to_fetch = set()
|
|
actors_to_fetch.add(
|
|
common_utils.recursive_getattr(
|
|
validated_data, "attributedTo", permissive=True
|
|
)
|
|
)
|
|
actors_to_fetch.add(
|
|
common_utils.recursive_getattr(
|
|
validated_data, "album.attributedTo", permissive=True
|
|
)
|
|
)
|
|
artist_credit = (
|
|
common_utils.recursive_getattr(
|
|
validated_data, "artist_credit", permissive=True
|
|
)
|
|
or []
|
|
)
|
|
album_artists_credit = (
|
|
common_utils.recursive_getattr(
|
|
validated_data, "album.artist_credit", permissive=True
|
|
)
|
|
or []
|
|
)
|
|
|
|
for ac in artist_credit + album_artists_credit:
|
|
actors_to_fetch.add(ac["artist"].get("attributedTo"))
|
|
|
|
for url in actors_to_fetch:
|
|
if not url:
|
|
continue
|
|
references[url] = actors.get_actor(url)
|
|
metadata = music_tasks.federation_audio_track_to_metadata(
|
|
validated_data, references
|
|
)
|
|
|
|
from_activity = self.context.get("activity")
|
|
if from_activity:
|
|
metadata["from_activity_id"] = from_activity.pk
|
|
track = music_tasks.get_track_from_import_metadata(
|
|
metadata, update_cover=True, query_mb=False
|
|
)
|
|
return track
|
|
|
|
def update(self, obj, validated_data):
|
|
if validated_data.get("license"):
|
|
validated_data["license"] = licenses.match(validated_data["license"])
|
|
return super().update(obj, validated_data)
|
|
|
|
|
|
class UploadSerializer(jsonld.JsonLdSerializer):
|
|
type = serializers.ChoiceField(choices=[contexts.AS.Audio])
|
|
id = serializers.URLField(max_length=500)
|
|
library = serializers.URLField(max_length=500)
|
|
url = LinkSerializer(allowed_mimetypes=["audio/*"])
|
|
published = serializers.DateTimeField()
|
|
updated = serializers.DateTimeField(required=False, allow_null=True)
|
|
bitrate = serializers.IntegerField(min_value=0)
|
|
size = serializers.IntegerField(min_value=0)
|
|
duration = serializers.IntegerField(min_value=0)
|
|
|
|
track = TrackSerializer(required=True)
|
|
|
|
class Meta:
|
|
model = music_models.Upload
|
|
jsonld_mapping = {
|
|
"track": jsonld.first_obj(contexts.FW.track),
|
|
"library": jsonld.first_id(contexts.FW.library),
|
|
"url": jsonld.first_obj(contexts.AS.url),
|
|
"published": jsonld.first_val(contexts.AS.published),
|
|
"updated": jsonld.first_val(contexts.AS.updated),
|
|
"duration": jsonld.first_val(contexts.AS.duration),
|
|
"bitrate": jsonld.first_val(contexts.FW.bitrate),
|
|
"size": jsonld.first_val(contexts.FW.size),
|
|
}
|
|
|
|
def validate_url(self, v):
|
|
try:
|
|
v["href"]
|
|
except (KeyError, TypeError):
|
|
raise serializers.ValidationError("Missing href")
|
|
|
|
try:
|
|
media_type = v["mediaType"]
|
|
except (KeyError, TypeError):
|
|
raise serializers.ValidationError("Missing mediaType")
|
|
|
|
if not media_type or not media_type.startswith("audio/"):
|
|
raise serializers.ValidationError("Invalid mediaType")
|
|
|
|
return v
|
|
|
|
def validate_library(self, v):
|
|
lb = self.context.get("library")
|
|
if lb:
|
|
if lb.fid != v:
|
|
raise serializers.ValidationError("Invalid library")
|
|
return lb
|
|
|
|
actor = self.context.get("actor")
|
|
|
|
try:
|
|
library = utils.retrieve_ap_object(
|
|
v,
|
|
actor=self.context.get("fetch_actor"),
|
|
queryset=music_models.Library,
|
|
serializer_class=LibrarySerializer,
|
|
)
|
|
except Exception:
|
|
raise serializers.ValidationError("Invalid library")
|
|
if actor and library.actor != actor:
|
|
raise serializers.ValidationError("Invalid library")
|
|
return library
|
|
|
|
def update(self, instance, validated_data):
|
|
return self.create(validated_data)
|
|
|
|
@transaction.atomic
|
|
def create(self, validated_data):
|
|
instance = self.instance or None
|
|
if not self.instance:
|
|
try:
|
|
instance = music_models.Upload.objects.get(fid=validated_data["id"])
|
|
except music_models.Upload.DoesNotExist:
|
|
pass
|
|
|
|
if instance:
|
|
data = {
|
|
"mimetype": validated_data["url"]["mediaType"],
|
|
"source": validated_data["url"]["href"],
|
|
"creation_date": validated_data["published"],
|
|
"modification_date": validated_data.get("updated"),
|
|
"duration": validated_data["duration"],
|
|
"size": validated_data["size"],
|
|
"bitrate": validated_data["bitrate"],
|
|
"import_status": "finished",
|
|
}
|
|
return music_models.Upload.objects.update_or_create(
|
|
fid=validated_data["id"], defaults=data
|
|
)[0]
|
|
else:
|
|
track = TrackSerializer(
|
|
context={"activity": self.context.get("activity")}
|
|
).create(validated_data["track"])
|
|
|
|
data = {
|
|
"fid": validated_data["id"],
|
|
"mimetype": validated_data["url"]["mediaType"],
|
|
"source": validated_data["url"]["href"],
|
|
"creation_date": validated_data["published"],
|
|
"modification_date": validated_data.get("updated"),
|
|
"track": track,
|
|
"duration": validated_data["duration"],
|
|
"size": validated_data["size"],
|
|
"bitrate": validated_data["bitrate"],
|
|
"library": validated_data["library"],
|
|
"from_activity": self.context.get("activity"),
|
|
"import_status": "finished",
|
|
}
|
|
return music_models.Upload.objects.create(**data)
|
|
|
|
def to_representation(self, instance):
|
|
track = instance.track
|
|
d = {
|
|
"type": "Audio",
|
|
"id": instance.get_federation_id(),
|
|
"library": instance.library.fid,
|
|
"name": track.full_name,
|
|
"published": instance.creation_date.isoformat(),
|
|
"bitrate": instance.bitrate,
|
|
"size": instance.size,
|
|
"duration": instance.duration,
|
|
"url": [
|
|
{
|
|
"href": utils.full_url(instance.listen_url_no_download),
|
|
"type": "Link",
|
|
"mediaType": instance.mimetype,
|
|
},
|
|
{
|
|
"type": "Link",
|
|
"mediaType": "text/html",
|
|
"href": utils.full_url(instance.track.get_absolute_url()),
|
|
},
|
|
],
|
|
"track": TrackSerializer(track, context={"include_ap_context": False}).data,
|
|
"to": contexts.AS.Public
|
|
if instance.library.privacy_level == "everyone"
|
|
else "",
|
|
"attributedTo": instance.library.actor.fid,
|
|
}
|
|
if instance.modification_date:
|
|
d["updated"] = instance.modification_date.isoformat()
|
|
|
|
if self.context.get("include_ap_context", self.parent is None):
|
|
d["@context"] = jsonld.get_default_context()
|
|
return d
|
|
|
|
|
|
class ActorDeleteSerializer(jsonld.JsonLdSerializer):
|
|
fid = serializers.URLField(max_length=500)
|
|
|
|
class Meta:
|
|
jsonld_mapping = {"fid": jsonld.first_id(contexts.AS.object)}
|
|
|
|
|
|
class FlagSerializer(jsonld.JsonLdSerializer):
|
|
type = serializers.ChoiceField(choices=[contexts.AS.Flag])
|
|
id = serializers.URLField(max_length=500)
|
|
object = serializers.URLField(max_length=500)
|
|
content = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
|
actor = serializers.URLField(max_length=500)
|
|
type = serializers.ListField(
|
|
child=TagSerializer(), min_length=0, required=False, allow_null=True
|
|
)
|
|
|
|
class Meta:
|
|
jsonld_mapping = {
|
|
"object": jsonld.first_id(contexts.AS.object),
|
|
"content": jsonld.first_val(contexts.AS.content),
|
|
"actor": jsonld.first_id(contexts.AS.actor),
|
|
"type": jsonld.raw(contexts.AS.tag),
|
|
}
|
|
|
|
def validate_object(self, v):
|
|
try:
|
|
return utils.get_object_by_fid(v, local=True)
|
|
except ObjectDoesNotExist:
|
|
raise serializers.ValidationError(f"Unknown id {v} for reported object")
|
|
|
|
def validate_type(self, tags):
|
|
if tags:
|
|
for tag in tags:
|
|
if tag["name"] in dict(moderation_models.REPORT_TYPES):
|
|
return tag["name"]
|
|
return "other"
|
|
|
|
def validate_actor(self, v):
|
|
try:
|
|
return models.Actor.objects.get(fid=v, domain=self.context["actor"].domain)
|
|
except models.Actor.DoesNotExist:
|
|
raise serializers.ValidationError("Invalid actor")
|
|
|
|
def validate(self, data):
|
|
validated_data = super().validate(data)
|
|
|
|
return validated_data
|
|
|
|
def create(self, validated_data):
|
|
kwargs = {
|
|
"target": validated_data["object"],
|
|
"target_owner": moderation_serializers.get_target_owner(
|
|
validated_data["object"]
|
|
),
|
|
"target_state": moderation_serializers.get_target_state(
|
|
validated_data["object"]
|
|
),
|
|
"type": validated_data.get("type", "other"),
|
|
"summary": validated_data.get("content"),
|
|
"submitter": validated_data["actor"],
|
|
}
|
|
|
|
report, created = moderation_models.Report.objects.update_or_create(
|
|
fid=validated_data["id"],
|
|
defaults=kwargs,
|
|
)
|
|
moderation_signals.report_created.send(sender=None, report=report)
|
|
return report
|
|
|
|
def to_representation(self, instance):
|
|
d = {
|
|
"type": "Flag",
|
|
"id": instance.get_federation_id(),
|
|
"actor": actors.get_service_actor().fid,
|
|
"object": [instance.target.fid],
|
|
"content": instance.summary,
|
|
"tag": [repr_tag(instance.type)],
|
|
}
|
|
|
|
if self.context.get("include_ap_context", self.parent is None):
|
|
d["@context"] = jsonld.get_default_context()
|
|
return d
|
|
|
|
|
|
class NodeInfoLinkSerializer(serializers.Serializer):
|
|
href = serializers.URLField()
|
|
rel = serializers.URLField()
|
|
|
|
|
|
class NodeInfoSerializer(serializers.Serializer):
|
|
links = serializers.ListField(child=NodeInfoLinkSerializer(), min_length=1)
|
|
|
|
|
|
class ChannelOutboxSerializer(PaginatedCollectionSerializer):
|
|
type = serializers.ChoiceField(choices=[contexts.AS.OrderedCollection])
|
|
|
|
class Meta:
|
|
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
|
|
|
|
def to_representation(self, channel):
|
|
conf = {
|
|
"id": channel.actor.outbox_url,
|
|
"page_size": 100,
|
|
"attributedTo": channel.actor,
|
|
"actor": channel.actor,
|
|
"items": channel.library.uploads.for_federation()
|
|
.order_by("-creation_date")
|
|
.filter(track__artist_credit__artist=channel.artist),
|
|
"type": "OrderedCollection",
|
|
}
|
|
r = super().to_representation(conf)
|
|
return r
|
|
|
|
|
|
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 = serializers.CharField()
|
|
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)
|
|
attributedTo = serializers.URLField(max_length=500, required=False)
|
|
copyright = serializers.CharField(
|
|
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_blank=True,
|
|
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),
|
|
"attributedTo": jsonld.first_id(contexts.AS.attributedTo),
|
|
}
|
|
|
|
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_credit__artist__channel=self.context["channel"]
|
|
),
|
|
)
|
|
|
|
def validate(self, data):
|
|
if not self.context.get("channel"):
|
|
if not data.get("attributedTo"):
|
|
raise serializers.ValidationError(
|
|
"Missing channel context and no attributedTo available"
|
|
)
|
|
actor = actors.get_actor(data["attributedTo"])
|
|
if not actor.get_channel():
|
|
raise serializers.ValidationError("Not a channel")
|
|
self.context["channel"] = actor.get_channel()
|
|
if data.get("album"):
|
|
data["album"] = self._validate_album(data["album"])
|
|
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.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": "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 sorted(set(tags))]
|
|
data["summary"] = " ".join([f"#{name}" for name in tags])
|
|
|
|
if self.context.get("include_ap_context", True):
|
|
data["@context"] = jsonld.get_default_context()
|
|
|
|
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"],
|
|
"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(
|
|
fid=validated_data["id"],
|
|
defaults=track_defaults,
|
|
)
|
|
|
|
# only one artist_credit per channel
|
|
query = (
|
|
Q(
|
|
artist=channel.artist,
|
|
)
|
|
& Q(credit__iexact=channel.artist.name)
|
|
& Q(joinphrase="")
|
|
)
|
|
defaults = {
|
|
"artist": channel.artist,
|
|
"joinphrase": "",
|
|
"credit": channel.artist.name,
|
|
}
|
|
|
|
ac_obj = music_tasks.get_best_candidate_or_create(
|
|
music_models.ArtistCredit,
|
|
query,
|
|
defaults=defaults,
|
|
sort_fields=["mbid", "fid"],
|
|
)
|
|
track.artist_credit.set([ac_obj[0].id])
|
|
|
|
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(jsonld.JsonLdSerializer):
|
|
object = serializers.DictField()
|
|
|
|
class Meta:
|
|
jsonld_mapping = {
|
|
"object": jsonld.first_obj(contexts.AS.object),
|
|
}
|
|
|
|
def to_representation(self, upload):
|
|
payload = {
|
|
"@context": jsonld.get_default_context(),
|
|
"type": self.context.get("type", "Create"),
|
|
"id": utils.full_url(
|
|
reverse(
|
|
"federation:music:uploads-activity", kwargs={"uuid": upload.uuid}
|
|
)
|
|
),
|
|
"actor": upload.library.channel.actor.fid,
|
|
"object": ChannelUploadSerializer(
|
|
upload, context={"include_ap_context": False}
|
|
).data,
|
|
}
|
|
if self.context.get("activity_id_suffix"):
|
|
payload["id"] = os.path.join(
|
|
payload["id"], self.context["activity_id_suffix"]
|
|
)
|
|
|
|
return payload
|
|
|
|
def validate(self, validated_data):
|
|
serializer = ChannelUploadSerializer(
|
|
data=validated_data["object"], context=self.context, jsonld_expand=False
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
return {"audio_serializer": serializer}
|
|
|
|
def save(self, **kwargs):
|
|
return self.validated_data["audio_serializer"].save(**kwargs)
|
|
|
|
|
|
class DeleteSerializer(jsonld.JsonLdSerializer):
|
|
object = serializers.URLField(max_length=500)
|
|
type = serializers.ChoiceField(choices=[contexts.AS.Delete])
|
|
|
|
class Meta:
|
|
jsonld_mapping = {"object": jsonld.first_id(contexts.AS.object)}
|
|
|
|
def validate_object(self, url):
|
|
try:
|
|
obj = utils.get_object_by_fid(url)
|
|
except utils.ObjectDoesNotExist:
|
|
raise serializers.ValidationError(f"No object matching {url}")
|
|
if isinstance(obj, music_models.Upload):
|
|
obj = obj.track
|
|
|
|
return obj
|
|
|
|
def validate(self, validated_data):
|
|
if not utils.can_manage(
|
|
validated_data["object"].attributed_to, self.context["actor"]
|
|
):
|
|
raise serializers.ValidationError("You cannot delete this object")
|
|
return validated_data
|
|
|
|
|
|
class IndexSerializer(jsonld.JsonLdSerializer):
|
|
type = serializers.ChoiceField(
|
|
choices=[contexts.AS.Collection, contexts.AS.OrderedCollection]
|
|
)
|
|
totalItems = serializers.IntegerField(min_value=0)
|
|
id = serializers.URLField(max_length=500)
|
|
first = serializers.URLField(max_length=500)
|
|
last = serializers.URLField(max_length=500)
|
|
|
|
class Meta:
|
|
jsonld_mapping = PAGINATED_COLLECTION_JSONLD_MAPPING
|
|
|
|
def to_representation(self, conf):
|
|
paginator = Paginator(conf["items"], conf["page_size"])
|
|
first = common_utils.set_query_parameter(conf["id"], page=1)
|
|
current = first
|
|
last = common_utils.set_query_parameter(conf["id"], page=paginator.num_pages)
|
|
d = {
|
|
"id": conf["id"],
|
|
"totalItems": paginator.count,
|
|
"type": "OrderedCollection",
|
|
"current": current,
|
|
"first": first,
|
|
"last": last,
|
|
}
|
|
if self.context.get("include_ap_context", True):
|
|
d["@context"] = jsonld.get_default_context()
|
|
return d
|