352 lines
13 KiB
Python
352 lines
13 KiB
Python
from django.db import transaction
|
|
|
|
from rest_framework import serializers
|
|
|
|
from funkwhale_api.common import serializers as common_serializers
|
|
from funkwhale_api.common import utils as common_utils
|
|
from funkwhale_api.common import locales
|
|
from funkwhale_api.federation import serializers as federation_serializers
|
|
from funkwhale_api.federation import utils as federation_utils
|
|
from funkwhale_api.music import models as music_models
|
|
from funkwhale_api.music import serializers as music_serializers
|
|
from funkwhale_api.tags import models as tags_models
|
|
from funkwhale_api.tags import serializers as tags_serializers
|
|
|
|
from . import categories
|
|
from . import models
|
|
|
|
|
|
class ChannelMetadataSerializer(serializers.Serializer):
|
|
itunes_category = serializers.ChoiceField(
|
|
choices=categories.ITUNES_CATEGORIES, required=True
|
|
)
|
|
itunes_subcategory = serializers.CharField(required=False)
|
|
language = serializers.ChoiceField(required=True, choices=locales.ISO_639_CHOICES)
|
|
copyright = serializers.CharField(required=False, allow_null=True, max_length=255)
|
|
owner_name = serializers.CharField(required=False, allow_null=True, max_length=255)
|
|
owner_email = serializers.EmailField(required=False, allow_null=True)
|
|
explicit = serializers.BooleanField(required=False)
|
|
|
|
def validate(self, validated_data):
|
|
validated_data = super().validate(validated_data)
|
|
subcategory = self._validate_itunes_subcategory(
|
|
validated_data["itunes_category"], validated_data.get("itunes_subcategory")
|
|
)
|
|
if subcategory:
|
|
validated_data["itunes_subcategory"] = subcategory
|
|
return validated_data
|
|
|
|
def _validate_itunes_subcategory(self, parent, child):
|
|
if not child:
|
|
return
|
|
|
|
if child not in categories.ITUNES_CATEGORIES[parent]:
|
|
raise serializers.ValidationError(
|
|
'"{}" is not a valid subcategory for "{}"'.format(child, parent)
|
|
)
|
|
|
|
return child
|
|
|
|
|
|
class ChannelCreateSerializer(serializers.Serializer):
|
|
name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
|
username = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
|
description = common_serializers.ContentSerializer(allow_null=True)
|
|
tags = tags_serializers.TagsListField()
|
|
content_category = serializers.ChoiceField(
|
|
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
|
|
)
|
|
metadata = serializers.DictField(required=False)
|
|
|
|
def validate(self, validated_data):
|
|
validated_data = super().validate(validated_data)
|
|
metadata = validated_data.pop("metadata", {})
|
|
if validated_data["content_category"] == "podcast":
|
|
metadata_serializer = ChannelMetadataSerializer(data=metadata)
|
|
metadata_serializer.is_valid(raise_exception=True)
|
|
metadata = metadata_serializer.validated_data
|
|
validated_data["metadata"] = metadata
|
|
return validated_data
|
|
|
|
@transaction.atomic
|
|
def create(self, validated_data):
|
|
description = validated_data.get("description")
|
|
artist = music_models.Artist.objects.create(
|
|
attributed_to=validated_data["attributed_to"],
|
|
name=validated_data["name"],
|
|
content_category=validated_data["content_category"],
|
|
)
|
|
description_obj = common_utils.attach_content(
|
|
artist, "description", description
|
|
)
|
|
|
|
if validated_data.get("tags", []):
|
|
tags_models.set_tags(artist, *validated_data["tags"])
|
|
|
|
channel = models.Channel(
|
|
artist=artist,
|
|
attributed_to=validated_data["attributed_to"],
|
|
metadata=validated_data["metadata"],
|
|
)
|
|
summary = description_obj.rendered if description_obj else None
|
|
channel.actor = models.generate_actor(
|
|
validated_data["username"], summary=summary, name=validated_data["name"],
|
|
)
|
|
|
|
channel.library = music_models.Library.objects.create(
|
|
name=channel.actor.preferred_username,
|
|
privacy_level="everyone",
|
|
actor=validated_data["attributed_to"],
|
|
)
|
|
channel.save()
|
|
return channel
|
|
|
|
def to_representation(self, obj):
|
|
return ChannelSerializer(obj).data
|
|
|
|
|
|
NOOP = object()
|
|
|
|
|
|
class ChannelUpdateSerializer(serializers.Serializer):
|
|
name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
|
description = common_serializers.ContentSerializer(allow_null=True)
|
|
tags = tags_serializers.TagsListField()
|
|
content_category = serializers.ChoiceField(
|
|
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
|
|
)
|
|
metadata = serializers.DictField(required=False)
|
|
|
|
def validate(self, validated_data):
|
|
validated_data = super().validate(validated_data)
|
|
require_metadata_validation = False
|
|
new_content_category = validated_data.get("content_category")
|
|
metadata = validated_data.pop("metadata", NOOP)
|
|
if (
|
|
new_content_category == "podcast"
|
|
and self.instance.artist.content_category != "postcast"
|
|
):
|
|
# updating channel, setting as podcast
|
|
require_metadata_validation = True
|
|
elif self.instance.artist.content_category == "postcast" and metadata != NOOP:
|
|
# channel is podcast, and metadata was updated
|
|
require_metadata_validation = True
|
|
else:
|
|
metadata = self.instance.metadata
|
|
|
|
if require_metadata_validation:
|
|
metadata_serializer = ChannelMetadataSerializer(data=metadata)
|
|
metadata_serializer.is_valid(raise_exception=True)
|
|
metadata = metadata_serializer.validated_data
|
|
|
|
validated_data["metadata"] = metadata
|
|
return validated_data
|
|
|
|
@transaction.atomic
|
|
def update(self, obj, validated_data):
|
|
if validated_data.get("tags") is not None:
|
|
tags_models.set_tags(obj.artist, *validated_data["tags"])
|
|
actor_update_fields = []
|
|
artist_update_fields = []
|
|
|
|
obj.metadata = validated_data["metadata"]
|
|
obj.save(update_fields=["metadata"])
|
|
|
|
if "description" in validated_data:
|
|
description_obj = common_utils.attach_content(
|
|
obj.artist, "description", validated_data["description"]
|
|
)
|
|
if description_obj:
|
|
actor_update_fields.append(("summary", description_obj.rendered))
|
|
|
|
if "name" in validated_data:
|
|
actor_update_fields.append(("name", validated_data["name"]))
|
|
artist_update_fields.append(("name", validated_data["name"]))
|
|
|
|
if "content_category" in validated_data:
|
|
artist_update_fields.append(
|
|
("content_category", validated_data["content_category"])
|
|
)
|
|
|
|
if actor_update_fields:
|
|
for field, value in actor_update_fields:
|
|
setattr(obj.actor, field, value)
|
|
obj.actor.save(update_fields=[f for f, _ in actor_update_fields])
|
|
|
|
if artist_update_fields:
|
|
for field, value in artist_update_fields:
|
|
setattr(obj.artist, field, value)
|
|
obj.artist.save(update_fields=[f for f, _ in artist_update_fields])
|
|
|
|
return obj
|
|
|
|
def to_representation(self, obj):
|
|
return ChannelSerializer(obj).data
|
|
|
|
|
|
class ChannelSerializer(serializers.ModelSerializer):
|
|
artist = serializers.SerializerMethodField()
|
|
actor = federation_serializers.APIActorSerializer()
|
|
attributed_to = federation_serializers.APIActorSerializer()
|
|
|
|
class Meta:
|
|
model = models.Channel
|
|
fields = [
|
|
"uuid",
|
|
"artist",
|
|
"attributed_to",
|
|
"actor",
|
|
"creation_date",
|
|
"metadata",
|
|
]
|
|
|
|
def get_artist(self, obj):
|
|
return music_serializers.serialize_artist_simple(obj.artist)
|
|
|
|
def to_representation(self, obj):
|
|
data = super().to_representation(obj)
|
|
if self.context.get("subscriptions_count"):
|
|
data["subscriptions_count"] = self.get_subscriptions_count(obj)
|
|
return data
|
|
|
|
def get_subscriptions_count(self, obj):
|
|
return obj.actor.received_follows.exclude(approved=False).count()
|
|
|
|
|
|
class SubscriptionSerializer(serializers.Serializer):
|
|
approved = serializers.BooleanField(read_only=True)
|
|
fid = serializers.URLField(read_only=True)
|
|
uuid = serializers.UUIDField(read_only=True)
|
|
creation_date = serializers.DateTimeField(read_only=True)
|
|
|
|
def to_representation(self, obj):
|
|
data = super().to_representation(obj)
|
|
data["channel"] = ChannelSerializer(obj.target.channel).data
|
|
return data
|
|
|
|
|
|
# RSS related stuff
|
|
# https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
|
|
# is extremely useful
|
|
|
|
|
|
def rss_date(dt):
|
|
return dt.strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
|
|
|
|
def rss_duration(seconds):
|
|
if not seconds:
|
|
return "00:00:00"
|
|
full_hours = seconds // 3600
|
|
full_minutes = (seconds - (full_hours * 3600)) // 60
|
|
remaining_seconds = seconds - (full_hours * 3600) - (full_minutes * 60)
|
|
return "{}:{}:{}".format(
|
|
str(full_hours).zfill(2),
|
|
str(full_minutes).zfill(2),
|
|
str(remaining_seconds).zfill(2),
|
|
)
|
|
|
|
|
|
def rss_serialize_item(upload):
|
|
data = {
|
|
"title": [{"value": upload.track.title}],
|
|
"itunes:title": [{"value": upload.track.title}],
|
|
"guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
|
|
"pubDate": [{"value": rss_date(upload.creation_date)}],
|
|
"itunes:duration": [{"value": rss_duration(upload.duration)}],
|
|
"itunes:explicit": [{"value": "no"}],
|
|
"itunes:episodeType": [{"value": "full"}],
|
|
"itunes:season": [{"value": upload.track.disc_number or 1}],
|
|
"itunes:episode": [{"value": upload.track.position or 1}],
|
|
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
|
"enclosure": [
|
|
{
|
|
"url": upload.listen_url,
|
|
"length": upload.size or 0,
|
|
"type": upload.mimetype or "audio/mpeg",
|
|
}
|
|
],
|
|
}
|
|
if upload.track.description:
|
|
data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
|
|
data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
|
|
data["description"] = [{"value": upload.track.description.as_plain_text}]
|
|
data["content:encoded"] = data["itunes:summary"]
|
|
|
|
if upload.track.attachment_cover:
|
|
data["itunes:image"] = [
|
|
{"href": upload.track.attachment_cover.download_url_original}
|
|
]
|
|
|
|
tagged_items = getattr(upload.track, "_prefetched_tagged_items", [])
|
|
if tagged_items:
|
|
data["itunes:keywords"] = [
|
|
{"value": " ".join([ti.tag.name for ti in tagged_items])}
|
|
]
|
|
|
|
return data
|
|
|
|
|
|
def rss_serialize_channel(channel):
|
|
metadata = channel.metadata or {}
|
|
explicit = metadata.get("explicit", False)
|
|
copyright = metadata.get("copyright", "All rights reserved")
|
|
owner_name = metadata.get("owner_name", channel.attributed_to.display_name)
|
|
owner_email = metadata.get("owner_email")
|
|
itunes_category = metadata.get("itunes_category")
|
|
itunes_subcategory = metadata.get("itunes_subcategory")
|
|
language = metadata.get("language")
|
|
|
|
data = {
|
|
"title": [{"value": channel.artist.name}],
|
|
"copyright": [{"value": copyright}],
|
|
"itunes:explicit": [{"value": "no" if not explicit else "yes"}],
|
|
"itunes:author": [{"value": owner_name}],
|
|
"itunes:owner": [{"itunes:name": [{"value": owner_name}]}],
|
|
"itunes:type": [{"value": "episodic"}],
|
|
"link": [{"value": channel.get_absolute_url()}],
|
|
"atom:link": [
|
|
{
|
|
"href": channel.get_rss_url(),
|
|
"rel": "self",
|
|
"type": "application/rss+xml",
|
|
}
|
|
],
|
|
}
|
|
if language:
|
|
data["language"] = [{"value": language}]
|
|
|
|
if owner_email:
|
|
data["itunes:owner"][0]["itunes:email"] = [{"value": owner_email}]
|
|
|
|
if itunes_category:
|
|
node = {"text": itunes_category}
|
|
if itunes_subcategory:
|
|
node["itunes:category"] = [{"text": itunes_subcategory}]
|
|
data["itunes:category"] = [node]
|
|
|
|
if channel.artist.description:
|
|
data["itunes:subtitle"] = [{"value": channel.artist.description.truncate(255)}]
|
|
data["itunes:summary"] = [{"cdata_value": channel.artist.description.rendered}]
|
|
data["description"] = [{"value": channel.artist.description.as_plain_text}]
|
|
|
|
if channel.artist.attachment_cover:
|
|
data["itunes:image"] = [
|
|
{"href": channel.artist.attachment_cover.download_url_original}
|
|
]
|
|
|
|
tagged_items = getattr(channel.artist, "_prefetched_tagged_items", [])
|
|
|
|
if tagged_items:
|
|
data["itunes:keywords"] = [
|
|
{"value": " ".join([ti.tag.name for ti in tagged_items])}
|
|
]
|
|
|
|
return data
|
|
|
|
|
|
def rss_serialize_channel_full(channel, uploads):
|
|
channel_data = rss_serialize_channel(channel)
|
|
channel_data["item"] = [rss_serialize_item(upload) for upload in uploads]
|
|
return {"channel": channel_data}
|