See #170: RSS feeds for channels
This commit is contained in:
parent
a04b0b706b
commit
9c22a72ed1
|
@ -0,0 +1,111 @@
|
||||||
|
# from https://help.apple.com/itc/podcasts_connect/#/itc9267a2f12
|
||||||
|
ITUNES_CATEGORIES = {
|
||||||
|
"Arts": [
|
||||||
|
"Books",
|
||||||
|
"Design",
|
||||||
|
"Fashion & Beauty",
|
||||||
|
"Food",
|
||||||
|
"Performing Arts",
|
||||||
|
"Visual Arts",
|
||||||
|
],
|
||||||
|
"Business": [
|
||||||
|
"Careers",
|
||||||
|
"Entrepreneurship",
|
||||||
|
"Investing",
|
||||||
|
"Management",
|
||||||
|
"Marketing",
|
||||||
|
"Non-Profit",
|
||||||
|
],
|
||||||
|
"Comedy": ["Comedy Interviews", "Improv", "Stand-Up"],
|
||||||
|
"Education": ["Courses", "How To", "Language Learning", "Self-Improvement"],
|
||||||
|
"Fiction": ["Comedy Fiction", "Drama", "Science Fiction"],
|
||||||
|
"Government": [],
|
||||||
|
"History": [],
|
||||||
|
"Health & Fitness": [
|
||||||
|
"Alternative Health",
|
||||||
|
"Fitness",
|
||||||
|
"Medicine",
|
||||||
|
"Mental Health",
|
||||||
|
"Nutrition",
|
||||||
|
"Sexuality",
|
||||||
|
],
|
||||||
|
"Kids & Family": [
|
||||||
|
"Education for Kids",
|
||||||
|
"Parenting",
|
||||||
|
"Pets & Animals",
|
||||||
|
"Stories for Kids",
|
||||||
|
],
|
||||||
|
"Leisure": [
|
||||||
|
"Animation & Manga",
|
||||||
|
"Automotive",
|
||||||
|
"Aviation",
|
||||||
|
"Crafts",
|
||||||
|
"Games",
|
||||||
|
"Hobbies",
|
||||||
|
"Home & Garden",
|
||||||
|
"Video Games",
|
||||||
|
],
|
||||||
|
"Music": ["Music Commentary", "Music History", "Music Interviews"],
|
||||||
|
"News": [
|
||||||
|
"Business News",
|
||||||
|
"Daily News",
|
||||||
|
"Entertainment News",
|
||||||
|
"News Commentary",
|
||||||
|
"Politics",
|
||||||
|
"Sports News",
|
||||||
|
"Tech News",
|
||||||
|
],
|
||||||
|
"Religion & Spirituality": [
|
||||||
|
"Buddhism",
|
||||||
|
"Christianity",
|
||||||
|
"Hinduism",
|
||||||
|
"Islam",
|
||||||
|
"Judaism",
|
||||||
|
"Religion",
|
||||||
|
"Spirituality",
|
||||||
|
],
|
||||||
|
"Science": [
|
||||||
|
"Astronomy",
|
||||||
|
"Chemistry",
|
||||||
|
"Earth Sciences",
|
||||||
|
"Life Sciences",
|
||||||
|
"Mathematics",
|
||||||
|
"Natural Sciences",
|
||||||
|
"Nature",
|
||||||
|
"Physics",
|
||||||
|
"Social Sciences",
|
||||||
|
],
|
||||||
|
"Society & Culture": [
|
||||||
|
"Documentary",
|
||||||
|
"Personal Journals",
|
||||||
|
"Philosophy",
|
||||||
|
"Places & Travel",
|
||||||
|
"Relationships",
|
||||||
|
],
|
||||||
|
"Sports": [
|
||||||
|
"Baseball",
|
||||||
|
"Basketball",
|
||||||
|
"Cricket",
|
||||||
|
"Fantasy Sports",
|
||||||
|
"Football",
|
||||||
|
"Golf",
|
||||||
|
"Hockey",
|
||||||
|
"Rugby",
|
||||||
|
"Running",
|
||||||
|
"Soccer",
|
||||||
|
"Swimming",
|
||||||
|
"Tennis",
|
||||||
|
"Volleyball",
|
||||||
|
"Wilderness",
|
||||||
|
"Wrestling",
|
||||||
|
],
|
||||||
|
"Technology": [],
|
||||||
|
"True Crime": [],
|
||||||
|
"TV & Film": [
|
||||||
|
"After Shows",
|
||||||
|
"Film History",
|
||||||
|
"Film Interviews",
|
||||||
|
"Film Reviews",
|
||||||
|
"TV Reviews",
|
||||||
|
],
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
music_factories.ArtistFactory,
|
music_factories.ArtistFactory,
|
||||||
attributed_to=factory.SelfAttribute("..attributed_to"),
|
attributed_to=factory.SelfAttribute("..attributed_to"),
|
||||||
)
|
)
|
||||||
|
metadata = factory.LazyAttribute(lambda o: {})
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = "audio.Channel"
|
model = "audio.Channel"
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 2.2.9 on 2020-01-31 06:24
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields.jsonb
|
||||||
|
import django.core.serializers.json
|
||||||
|
from django.db import migrations
|
||||||
|
import funkwhale_api.audio.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('audio', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='channel',
|
||||||
|
name='metadata',
|
||||||
|
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, default=funkwhale_api.audio.models.empty_dict, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=50000),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,14 +1,22 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
from django.contrib.postgres.fields import JSONField
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.federation import keys
|
from funkwhale_api.federation import keys
|
||||||
from funkwhale_api.federation import models as federation_models
|
from funkwhale_api.federation import models as federation_models
|
||||||
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
from funkwhale_api.users import models as user_models
|
from funkwhale_api.users import models as user_models
|
||||||
|
|
||||||
|
|
||||||
|
def empty_dict():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class Channel(models.Model):
|
class Channel(models.Model):
|
||||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||||
artist = models.OneToOneField(
|
artist = models.OneToOneField(
|
||||||
|
@ -29,6 +37,19 @@ class Channel(models.Model):
|
||||||
)
|
)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
|
# metadata to enhance rss feed
|
||||||
|
metadata = JSONField(
|
||||||
|
default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return federation_utils.full_url("/channels/{}".format(self.uuid))
|
||||||
|
|
||||||
|
def get_rss_url(self):
|
||||||
|
return federation_utils.full_url(
|
||||||
|
reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_actor(username, **kwargs):
|
def generate_actor(username, **kwargs):
|
||||||
actor_data = user_models.get_actor_data(username, **kwargs)
|
actor_data = user_models.get_actor_data(username, **kwargs)
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from rest_framework import negotiation
|
||||||
|
from rest_framework import renderers
|
||||||
|
|
||||||
|
from funkwhale_api.subsonic.renderers import dict_to_xml_tree
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastRSSRenderer(renderers.JSONRenderer):
|
||||||
|
media_type = "application/rss+xml"
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
if not data:
|
||||||
|
# when stream view is called, we don't have any data
|
||||||
|
return super().render(data, accepted_media_type, renderer_context)
|
||||||
|
final = {
|
||||||
|
"version": "2.0",
|
||||||
|
"xmlns:atom": "http://www.w3.org/2005/Atom",
|
||||||
|
"xmlns:itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||||
|
"xmlns:media": "http://search.yahoo.com/mrss/",
|
||||||
|
}
|
||||||
|
final.update(data)
|
||||||
|
tree = dict_to_xml_tree("rss", final)
|
||||||
|
return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
|
||||||
|
tree, encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastRSSContentNegociation(negotiation.DefaultContentNegotiation):
|
||||||
|
def select_renderer(self, request, renderers, format_suffix=None):
|
||||||
|
|
||||||
|
return (PodcastRSSRenderer(), PodcastRSSRenderer.media_type)
|
|
@ -4,15 +4,50 @@ from rest_framework import serializers
|
||||||
|
|
||||||
from funkwhale_api.common import serializers as common_serializers
|
from funkwhale_api.common import serializers as common_serializers
|
||||||
from funkwhale_api.common import utils as common_utils
|
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 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 models as music_models
|
||||||
from funkwhale_api.music import serializers as music_serializers
|
from funkwhale_api.music import serializers as music_serializers
|
||||||
from funkwhale_api.tags import models as tags_models
|
from funkwhale_api.tags import models as tags_models
|
||||||
from funkwhale_api.tags import serializers as tags_serializers
|
from funkwhale_api.tags import serializers as tags_serializers
|
||||||
|
|
||||||
|
from . import categories
|
||||||
from . import models
|
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):
|
class ChannelCreateSerializer(serializers.Serializer):
|
||||||
name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
||||||
username = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
username = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
||||||
|
@ -21,6 +56,17 @@ class ChannelCreateSerializer(serializers.Serializer):
|
||||||
content_category = serializers.ChoiceField(
|
content_category = serializers.ChoiceField(
|
||||||
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
|
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
|
@transaction.atomic
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
@ -38,7 +84,9 @@ class ChannelCreateSerializer(serializers.Serializer):
|
||||||
tags_models.set_tags(artist, *validated_data["tags"])
|
tags_models.set_tags(artist, *validated_data["tags"])
|
||||||
|
|
||||||
channel = models.Channel(
|
channel = models.Channel(
|
||||||
artist=artist, attributed_to=validated_data["attributed_to"]
|
artist=artist,
|
||||||
|
attributed_to=validated_data["attributed_to"],
|
||||||
|
metadata=validated_data["metadata"],
|
||||||
)
|
)
|
||||||
summary = description_obj.rendered if description_obj else None
|
summary = description_obj.rendered if description_obj else None
|
||||||
channel.actor = models.generate_actor(
|
channel.actor = models.generate_actor(
|
||||||
|
@ -57,6 +105,9 @@ class ChannelCreateSerializer(serializers.Serializer):
|
||||||
return ChannelSerializer(obj).data
|
return ChannelSerializer(obj).data
|
||||||
|
|
||||||
|
|
||||||
|
NOOP = object()
|
||||||
|
|
||||||
|
|
||||||
class ChannelUpdateSerializer(serializers.Serializer):
|
class ChannelUpdateSerializer(serializers.Serializer):
|
||||||
name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
name = serializers.CharField(max_length=music_models.MAX_LENGTHS["ARTIST_NAME"])
|
||||||
description = common_serializers.ContentSerializer(allow_null=True)
|
description = common_serializers.ContentSerializer(allow_null=True)
|
||||||
|
@ -64,6 +115,32 @@ class ChannelUpdateSerializer(serializers.Serializer):
|
||||||
content_category = serializers.ChoiceField(
|
content_category = serializers.ChoiceField(
|
||||||
choices=music_models.ARTIST_CONTENT_CATEGORY_CHOICES
|
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
|
@transaction.atomic
|
||||||
def update(self, obj, validated_data):
|
def update(self, obj, validated_data):
|
||||||
|
@ -72,6 +149,9 @@ class ChannelUpdateSerializer(serializers.Serializer):
|
||||||
actor_update_fields = []
|
actor_update_fields = []
|
||||||
artist_update_fields = []
|
artist_update_fields = []
|
||||||
|
|
||||||
|
obj.metadata = validated_data["metadata"]
|
||||||
|
obj.save(update_fields=["metadata"])
|
||||||
|
|
||||||
if "description" in validated_data:
|
if "description" in validated_data:
|
||||||
description_obj = common_utils.attach_content(
|
description_obj = common_utils.attach_content(
|
||||||
obj.artist, "description", validated_data["description"]
|
obj.artist, "description", validated_data["description"]
|
||||||
|
@ -111,7 +191,14 @@ class ChannelSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Channel
|
model = models.Channel
|
||||||
fields = ["uuid", "artist", "attributed_to", "actor", "creation_date"]
|
fields = [
|
||||||
|
"uuid",
|
||||||
|
"artist",
|
||||||
|
"attributed_to",
|
||||||
|
"actor",
|
||||||
|
"creation_date",
|
||||||
|
"metadata",
|
||||||
|
]
|
||||||
|
|
||||||
def get_artist(self, obj):
|
def get_artist(self, obj):
|
||||||
return music_serializers.serialize_artist_simple(obj.artist)
|
return music_serializers.serialize_artist_simple(obj.artist)
|
||||||
|
@ -136,3 +223,129 @@ class SubscriptionSerializer(serializers.Serializer):
|
||||||
data = super().to_representation(obj)
|
data = super().to_representation(obj)
|
||||||
data["channel"] = ChannelSerializer(obj.target.channel).data
|
data["channel"] = ChannelSerializer(obj.target.channel).data
|
||||||
return 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}
|
||||||
|
|
|
@ -47,6 +47,16 @@ def channel_detail(request, uuid):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
metas.append(
|
||||||
|
{
|
||||||
|
"tag": "link",
|
||||||
|
"rel": "alternate",
|
||||||
|
"type": "application/rss+xml",
|
||||||
|
"href": obj.get_rss_url(),
|
||||||
|
"title": "{} - RSS Podcast Feed".format(obj.artist.name),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if obj.library.uploads.all().playable_by(None).exists():
|
if obj.library.uploads.all().playable_by(None).exists():
|
||||||
metas.append(
|
metas.append(
|
||||||
{
|
{
|
||||||
|
|
|
@ -6,14 +6,17 @@ from rest_framework import response
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
from django import http
|
from django import http
|
||||||
|
from django.db.models import Prefetch
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
|
|
||||||
from funkwhale_api.common import permissions
|
from funkwhale_api.common import permissions
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
from funkwhale_api.federation import models as federation_models
|
from funkwhale_api.federation import models as federation_models
|
||||||
|
from funkwhale_api.music import models as music_models
|
||||||
|
from funkwhale_api.music import views as music_views
|
||||||
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
from funkwhale_api.users.oauth import permissions as oauth_permissions
|
||||||
|
|
||||||
from . import filters, models, serializers
|
from . import filters, models, renderers, serializers
|
||||||
|
|
||||||
|
|
||||||
class ChannelsMixin(object):
|
class ChannelsMixin(object):
|
||||||
|
@ -37,7 +40,17 @@ class ChannelViewSet(
|
||||||
serializer_class = serializers.ChannelSerializer
|
serializer_class = serializers.ChannelSerializer
|
||||||
queryset = (
|
queryset = (
|
||||||
models.Channel.objects.all()
|
models.Channel.objects.all()
|
||||||
.prefetch_related("library", "attributed_to", "artist__description", "actor")
|
.prefetch_related(
|
||||||
|
"library",
|
||||||
|
"attributed_to",
|
||||||
|
"actor",
|
||||||
|
Prefetch(
|
||||||
|
"artist",
|
||||||
|
queryset=music_models.Artist.objects.select_related(
|
||||||
|
"attachment_cover", "description"
|
||||||
|
).prefetch_related(music_views.TAG_PREFETCH,),
|
||||||
|
),
|
||||||
|
)
|
||||||
.order_by("-creation_date")
|
.order_by("-creation_date")
|
||||||
)
|
)
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
|
@ -92,6 +105,30 @@ class ChannelViewSet(
|
||||||
request.user.actor.emitted_follows.filter(target=object.actor).delete()
|
request.user.actor.emitted_follows.filter(target=object.actor).delete()
|
||||||
return response.Response(status=204)
|
return response.Response(status=204)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=["get"],
|
||||||
|
permission_classes=[],
|
||||||
|
content_negotiation_class=renderers.PodcastRSSContentNegociation,
|
||||||
|
)
|
||||||
|
def rss(self, request, *args, **kwargs):
|
||||||
|
object = self.get_object()
|
||||||
|
uploads = (
|
||||||
|
object.library.uploads.playable_by(None)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"track",
|
||||||
|
queryset=music_models.Track.objects.select_related(
|
||||||
|
"attachment_cover", "description"
|
||||||
|
).prefetch_related(music_views.TAG_PREFETCH,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.select_related("track__attachment_cover", "track__description")
|
||||||
|
.order_by("-creation_date")
|
||||||
|
)[:50]
|
||||||
|
data = serializers.rss_serialize_channel_full(channel=object, uploads=uploads)
|
||||||
|
return response.Response(data, status=200)
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
context["subscriptions_count"] = self.action in ["retrieve", "create", "update"]
|
context["subscriptions_count"] = self.action in ["retrieve", "create", "update"]
|
||||||
|
|
|
@ -0,0 +1,191 @@
|
||||||
|
# from https://gist.github.com/carlopires/1262033/c52ef0f7ce4f58108619508308372edd8d0bd518
|
||||||
|
|
||||||
|
ISO_639_CHOICES = [
|
||||||
|
("ab", "Abkhaz"),
|
||||||
|
("aa", "Afar"),
|
||||||
|
("af", "Afrikaans"),
|
||||||
|
("ak", "Akan"),
|
||||||
|
("sq", "Albanian"),
|
||||||
|
("am", "Amharic"),
|
||||||
|
("ar", "Arabic"),
|
||||||
|
("an", "Aragonese"),
|
||||||
|
("hy", "Armenian"),
|
||||||
|
("as", "Assamese"),
|
||||||
|
("av", "Avaric"),
|
||||||
|
("ae", "Avestan"),
|
||||||
|
("ay", "Aymara"),
|
||||||
|
("az", "Azerbaijani"),
|
||||||
|
("bm", "Bambara"),
|
||||||
|
("ba", "Bashkir"),
|
||||||
|
("eu", "Basque"),
|
||||||
|
("be", "Belarusian"),
|
||||||
|
("bn", "Bengali"),
|
||||||
|
("bh", "Bihari"),
|
||||||
|
("bi", "Bislama"),
|
||||||
|
("bs", "Bosnian"),
|
||||||
|
("br", "Breton"),
|
||||||
|
("bg", "Bulgarian"),
|
||||||
|
("my", "Burmese"),
|
||||||
|
("ca", "Catalan; Valencian"),
|
||||||
|
("ch", "Chamorro"),
|
||||||
|
("ce", "Chechen"),
|
||||||
|
("ny", "Chichewa; Chewa; Nyanja"),
|
||||||
|
("zh", "Chinese"),
|
||||||
|
("cv", "Chuvash"),
|
||||||
|
("kw", "Cornish"),
|
||||||
|
("co", "Corsican"),
|
||||||
|
("cr", "Cree"),
|
||||||
|
("hr", "Croatian"),
|
||||||
|
("cs", "Czech"),
|
||||||
|
("da", "Danish"),
|
||||||
|
("dv", "Divehi; Maldivian;"),
|
||||||
|
("nl", "Dutch"),
|
||||||
|
("dz", "Dzongkha"),
|
||||||
|
("en", "English"),
|
||||||
|
("eo", "Esperanto"),
|
||||||
|
("et", "Estonian"),
|
||||||
|
("ee", "Ewe"),
|
||||||
|
("fo", "Faroese"),
|
||||||
|
("fj", "Fijian"),
|
||||||
|
("fi", "Finnish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("ff", "Fula"),
|
||||||
|
("gl", "Galician"),
|
||||||
|
("ka", "Georgian"),
|
||||||
|
("de", "German"),
|
||||||
|
("el", "Greek, Modern"),
|
||||||
|
("gn", "Guaraní"),
|
||||||
|
("gu", "Gujarati"),
|
||||||
|
("ht", "Haitian"),
|
||||||
|
("ha", "Hausa"),
|
||||||
|
("he", "Hebrew (modern)"),
|
||||||
|
("hz", "Herero"),
|
||||||
|
("hi", "Hindi"),
|
||||||
|
("ho", "Hiri Motu"),
|
||||||
|
("hu", "Hungarian"),
|
||||||
|
("ia", "Interlingua"),
|
||||||
|
("id", "Indonesian"),
|
||||||
|
("ie", "Interlingue"),
|
||||||
|
("ga", "Irish"),
|
||||||
|
("ig", "Igbo"),
|
||||||
|
("ik", "Inupiaq"),
|
||||||
|
("io", "Ido"),
|
||||||
|
("is", "Icelandic"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("iu", "Inuktitut"),
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("jv", "Javanese"),
|
||||||
|
("kl", "Kalaallisut"),
|
||||||
|
("kn", "Kannada"),
|
||||||
|
("kr", "Kanuri"),
|
||||||
|
("ks", "Kashmiri"),
|
||||||
|
("kk", "Kazakh"),
|
||||||
|
("km", "Khmer"),
|
||||||
|
("ki", "Kikuyu, Gikuyu"),
|
||||||
|
("rw", "Kinyarwanda"),
|
||||||
|
("ky", "Kirghiz, Kyrgyz"),
|
||||||
|
("kv", "Komi"),
|
||||||
|
("kg", "Kongo"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("ku", "Kurdish"),
|
||||||
|
("kj", "Kwanyama, Kuanyama"),
|
||||||
|
("la", "Latin"),
|
||||||
|
("lb", "Luxembourgish"),
|
||||||
|
("lg", "Luganda"),
|
||||||
|
("li", "Limburgish"),
|
||||||
|
("ln", "Lingala"),
|
||||||
|
("lo", "Lao"),
|
||||||
|
("lt", "Lithuanian"),
|
||||||
|
("lu", "Luba-Katanga"),
|
||||||
|
("lv", "Latvian"),
|
||||||
|
("gv", "Manx"),
|
||||||
|
("mk", "Macedonian"),
|
||||||
|
("mg", "Malagasy"),
|
||||||
|
("ms", "Malay"),
|
||||||
|
("ml", "Malayalam"),
|
||||||
|
("mt", "Maltese"),
|
||||||
|
("mi", "Māori"),
|
||||||
|
("mr", "Marathi (Marāṭhī)"),
|
||||||
|
("mh", "Marshallese"),
|
||||||
|
("mn", "Mongolian"),
|
||||||
|
("na", "Nauru"),
|
||||||
|
("nv", "Navajo, Navaho"),
|
||||||
|
("nb", "Norwegian Bokmål"),
|
||||||
|
("nd", "North Ndebele"),
|
||||||
|
("ne", "Nepali"),
|
||||||
|
("ng", "Ndonga"),
|
||||||
|
("nn", "Norwegian Nynorsk"),
|
||||||
|
("no", "Norwegian"),
|
||||||
|
("ii", "Nuosu"),
|
||||||
|
("nr", "South Ndebele"),
|
||||||
|
("oc", "Occitan"),
|
||||||
|
("oj", "Ojibwe, Ojibwa"),
|
||||||
|
("cu", "Old Church Slavonic"),
|
||||||
|
("om", "Oromo"),
|
||||||
|
("or", "Oriya"),
|
||||||
|
("os", "Ossetian, Ossetic"),
|
||||||
|
("pa", "Panjabi, Punjabi"),
|
||||||
|
("pi", "Pāli"),
|
||||||
|
("fa", "Persian"),
|
||||||
|
("pl", "Polish"),
|
||||||
|
("ps", "Pashto, Pushto"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("qu", "Quechua"),
|
||||||
|
("rm", "Romansh"),
|
||||||
|
("rn", "Kirundi"),
|
||||||
|
("ro", "Romanian, Moldavan"),
|
||||||
|
("ru", "Russian"),
|
||||||
|
("sa", "Sanskrit (Saṁskṛta)"),
|
||||||
|
("sc", "Sardinian"),
|
||||||
|
("sd", "Sindhi"),
|
||||||
|
("se", "Northern Sami"),
|
||||||
|
("sm", "Samoan"),
|
||||||
|
("sg", "Sango"),
|
||||||
|
("sr", "Serbian"),
|
||||||
|
("gd", "Scottish Gaelic"),
|
||||||
|
("sn", "Shona"),
|
||||||
|
("si", "Sinhala, Sinhalese"),
|
||||||
|
("sk", "Slovak"),
|
||||||
|
("sl", "Slovene"),
|
||||||
|
("so", "Somali"),
|
||||||
|
("st", "Southern Sotho"),
|
||||||
|
("es", "Spanish; Castilian"),
|
||||||
|
("su", "Sundanese"),
|
||||||
|
("sw", "Swahili"),
|
||||||
|
("ss", "Swati"),
|
||||||
|
("sv", "Swedish"),
|
||||||
|
("ta", "Tamil"),
|
||||||
|
("te", "Telugu"),
|
||||||
|
("tg", "Tajik"),
|
||||||
|
("th", "Thai"),
|
||||||
|
("ti", "Tigrinya"),
|
||||||
|
("bo", "Tibetan"),
|
||||||
|
("tk", "Turkmen"),
|
||||||
|
("tl", "Tagalog"),
|
||||||
|
("tn", "Tswana"),
|
||||||
|
("to", "Tonga"),
|
||||||
|
("tr", "Turkish"),
|
||||||
|
("ts", "Tsonga"),
|
||||||
|
("tt", "Tatar"),
|
||||||
|
("tw", "Twi"),
|
||||||
|
("ty", "Tahitian"),
|
||||||
|
("ug", "Uighur, Uyghur"),
|
||||||
|
("uk", "Ukrainian"),
|
||||||
|
("ur", "Urdu"),
|
||||||
|
("uz", "Uzbek"),
|
||||||
|
("ve", "Venda"),
|
||||||
|
("vi", "Vietnamese"),
|
||||||
|
("vo", "Volapük"),
|
||||||
|
("wa", "Walloon"),
|
||||||
|
("cy", "Welsh"),
|
||||||
|
("wo", "Wolof"),
|
||||||
|
("fy", "Western Frisian"),
|
||||||
|
("xh", "Xhosa"),
|
||||||
|
("yi", "Yiddish"),
|
||||||
|
("yo", "Yoruba"),
|
||||||
|
("za", "Zhuang, Chuang"),
|
||||||
|
("zu", "Zulu"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
ISO_639_BY_CODE = {code: name for code, name in ISO_639_CHOICES}
|
|
@ -301,6 +301,20 @@ class Content(models.Model):
|
||||||
|
|
||||||
return utils.render_html(self.text, self.content_type)
|
return utils.render_html(self.text, self.content_type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_plain_text(self):
|
||||||
|
from . import utils
|
||||||
|
|
||||||
|
return utils.render_plain_text(self.rendered)
|
||||||
|
|
||||||
|
def truncate(self, length):
|
||||||
|
text = self.as_plain_text
|
||||||
|
truncated = text[:length]
|
||||||
|
if len(truncated) < len(text):
|
||||||
|
truncated += "…"
|
||||||
|
|
||||||
|
return truncated
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_save, sender=Attachment)
|
@receiver(models.signals.post_save, sender=Attachment)
|
||||||
def warm_attachment_thumbnails(sender, instance, **kwargs):
|
def warm_attachment_thumbnails(sender, instance, **kwargs):
|
||||||
|
|
|
@ -291,6 +291,10 @@ def render_html(text, content_type):
|
||||||
return clean_html(rendered).strip().replace("\n", "")
|
return clean_html(rendered).strip().replace("\n", "")
|
||||||
|
|
||||||
|
|
||||||
|
def render_plain_text(html):
|
||||||
|
return bleach.clean(html, tags=[], strip=True)
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def attach_content(obj, field, content_data):
|
def attach_content(obj, field, content_data):
|
||||||
from . import models
|
from . import models
|
||||||
|
|
|
@ -322,6 +322,10 @@ class Actor(models.Model):
|
||||||
"https://{}/".format(domain)
|
"https://{}/".format(domain)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self):
|
||||||
|
return self.name or self.preferred_username
|
||||||
|
|
||||||
|
|
||||||
FETCH_STATUSES = [
|
FETCH_STATUSES = [
|
||||||
("pending", "Pending"),
|
("pending", "Pending"),
|
||||||
|
|
|
@ -55,6 +55,8 @@ def dict_to_xml_tree(root_tag, d, parent=None):
|
||||||
else:
|
else:
|
||||||
if key == "value":
|
if key == "value":
|
||||||
root.text = str(value)
|
root.text = str(value)
|
||||||
|
elif key == "cdata_value":
|
||||||
|
root.text = "<![CDATA[{}]]>".format(str(value))
|
||||||
else:
|
else:
|
||||||
root.set(key, str(value))
|
root.set(key, str(value))
|
||||||
return root
|
return root
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytz
|
||||||
|
|
||||||
from funkwhale_api.audio import serializers
|
from funkwhale_api.audio import serializers
|
||||||
from funkwhale_api.common import serializers as common_serializers
|
from funkwhale_api.common import serializers as common_serializers
|
||||||
from funkwhale_api.common import utils as common_utils
|
from funkwhale_api.common import utils as common_utils
|
||||||
from funkwhale_api.federation import serializers as federation_serializers
|
from funkwhale_api.federation import serializers as federation_serializers
|
||||||
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
from funkwhale_api.music import serializers as music_serializers
|
from funkwhale_api.music import serializers as music_serializers
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,6 +49,26 @@ def test_channel_serializer_create(factories):
|
||||||
assert channel.library.actor == attributed_to
|
assert channel.library.actor == attributed_to
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_serializer_create_podcast(factories):
|
||||||
|
attributed_to = factories["federation.Actor"](local=True)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
# TODO: cover
|
||||||
|
"name": "My channel",
|
||||||
|
"username": "mychannel",
|
||||||
|
"description": {"text": "This is my channel", "content_type": "text/markdown"},
|
||||||
|
"tags": ["hello", "world"],
|
||||||
|
"content_category": "podcast",
|
||||||
|
"metadata": {"itunes_category": "Sports", "language": "en"},
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = serializers.ChannelCreateSerializer(data=data)
|
||||||
|
assert serializer.is_valid(raise_exception=True) is True
|
||||||
|
|
||||||
|
channel = serializer.save(attributed_to=attributed_to)
|
||||||
|
assert channel.metadata == data["metadata"]
|
||||||
|
|
||||||
|
|
||||||
def test_channel_serializer_update(factories):
|
def test_channel_serializer_update(factories):
|
||||||
channel = factories["audio.Channel"](artist__set_tags=["rock"])
|
channel = factories["audio.Channel"](artist__set_tags=["rock"])
|
||||||
|
|
||||||
|
@ -74,6 +100,27 @@ def test_channel_serializer_update(factories):
|
||||||
assert channel.actor.name == data["name"]
|
assert channel.actor.name == data["name"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_serializer_update_podcast(factories):
|
||||||
|
channel = factories["audio.Channel"](artist__set_tags=["rock"])
|
||||||
|
|
||||||
|
data = {
|
||||||
|
# TODO: cover
|
||||||
|
"name": "My channel",
|
||||||
|
"description": {"text": "This is my channel", "content_type": "text/markdown"},
|
||||||
|
"tags": ["hello", "world"],
|
||||||
|
"content_category": "podcast",
|
||||||
|
"metadata": {"language": "en", "itunes_category": "Sports"},
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = serializers.ChannelUpdateSerializer(channel, data=data)
|
||||||
|
assert serializer.is_valid(raise_exception=True) is True
|
||||||
|
|
||||||
|
serializer.save()
|
||||||
|
channel.refresh_from_db()
|
||||||
|
|
||||||
|
assert channel.metadata == data["metadata"]
|
||||||
|
|
||||||
|
|
||||||
def test_channel_serializer_representation(factories, to_api_date):
|
def test_channel_serializer_representation(factories, to_api_date):
|
||||||
content = factories["common.Content"]()
|
content = factories["common.Content"]()
|
||||||
channel = factories["audio.Channel"](artist__description=content)
|
channel = factories["audio.Channel"](artist__description=content)
|
||||||
|
@ -86,6 +133,7 @@ def test_channel_serializer_representation(factories, to_api_date):
|
||||||
"attributed_to": federation_serializers.APIActorSerializer(
|
"attributed_to": federation_serializers.APIActorSerializer(
|
||||||
channel.attributed_to
|
channel.attributed_to
|
||||||
).data,
|
).data,
|
||||||
|
"metadata": {},
|
||||||
}
|
}
|
||||||
expected["artist"]["description"] = common_serializers.ContentSerializer(
|
expected["artist"]["description"] = common_serializers.ContentSerializer(
|
||||||
content
|
content
|
||||||
|
@ -115,3 +163,169 @@ def test_subscription_serializer(factories, to_api_date):
|
||||||
}
|
}
|
||||||
|
|
||||||
assert serializers.SubscriptionSerializer(subscription).data == expected
|
assert serializers.SubscriptionSerializer(subscription).data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_rss_item_serializer(factories):
|
||||||
|
description = factories["common.Content"]()
|
||||||
|
upload = factories["music.Upload"](
|
||||||
|
playable=True,
|
||||||
|
track__set_tags=["pop", "rock"],
|
||||||
|
track__description=description,
|
||||||
|
track__disc_number=4,
|
||||||
|
track__position=42,
|
||||||
|
)
|
||||||
|
setattr(
|
||||||
|
upload.track,
|
||||||
|
"_prefetched_tagged_items",
|
||||||
|
upload.track.tagged_items.order_by("tag__name"),
|
||||||
|
)
|
||||||
|
expected = {
|
||||||
|
"title": [{"value": upload.track.title}],
|
||||||
|
"itunes:title": [{"value": upload.track.title}],
|
||||||
|
"itunes:subtitle": [{"value": description.truncate(255)}],
|
||||||
|
"itunes:summary": [{"cdata_value": description.rendered}],
|
||||||
|
"description": [{"value": description.as_plain_text}],
|
||||||
|
"content:encoded": [{"cdata_value": description.rendered}],
|
||||||
|
"guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
|
||||||
|
"pubDate": [{"value": serializers.rss_date(upload.creation_date)}],
|
||||||
|
"itunes:duration": [{"value": serializers.rss_duration(upload.duration)}],
|
||||||
|
"itunes:keywords": [{"value": "pop rock"}],
|
||||||
|
"itunes:explicit": [{"value": "no"}],
|
||||||
|
"itunes:episodeType": [{"value": "full"}],
|
||||||
|
"itunes:season": [{"value": upload.track.disc_number}],
|
||||||
|
"itunes:episode": [{"value": upload.track.position}],
|
||||||
|
"itunes:image": [{"href": upload.track.attachment_cover.download_url_original}],
|
||||||
|
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
|
||||||
|
"enclosure": [
|
||||||
|
{"url": upload.listen_url, "length": upload.size, "type": upload.mimetype}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert serializers.rss_serialize_item(upload) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_rss_channel_serializer(factories):
|
||||||
|
metadata = {
|
||||||
|
"language": "fr",
|
||||||
|
"itunes_category": "Parent",
|
||||||
|
"itunes_subcategory": "Child",
|
||||||
|
"copyright": "Myself",
|
||||||
|
"owner_name": "Name",
|
||||||
|
"owner_email": "name@domain.com",
|
||||||
|
"explicit": True,
|
||||||
|
}
|
||||||
|
description = factories["common.Content"]()
|
||||||
|
channel = factories["audio.Channel"](
|
||||||
|
artist__set_tags=["pop", "rock"],
|
||||||
|
artist__description=description,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
setattr(
|
||||||
|
channel.artist,
|
||||||
|
"_prefetched_tagged_items",
|
||||||
|
channel.artist.tagged_items.order_by("tag__name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"title": [{"value": channel.artist.name}],
|
||||||
|
"language": [{"value": metadata["language"]}],
|
||||||
|
"copyright": [{"value": metadata["copyright"]}],
|
||||||
|
"itunes:subtitle": [{"value": description.truncate(255)}],
|
||||||
|
"itunes:summary": [{"cdata_value": description.rendered}],
|
||||||
|
"description": [{"value": description.as_plain_text}],
|
||||||
|
"itunes:keywords": [{"value": "pop rock"}],
|
||||||
|
"itunes:category": [
|
||||||
|
{
|
||||||
|
"text": metadata["itunes_category"],
|
||||||
|
"itunes:category": [{"text": metadata["itunes_subcategory"]}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"itunes:explicit": [{"value": "yes"}],
|
||||||
|
"itunes:owner": [
|
||||||
|
{
|
||||||
|
"itunes:name": [{"value": metadata["owner_name"]}],
|
||||||
|
"itunes:email": [{"value": metadata["owner_email"]}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"itunes:author": [{"value": metadata["owner_name"]}],
|
||||||
|
"itunes:type": [{"value": "episodic"}],
|
||||||
|
"itunes:image": [
|
||||||
|
{"href": channel.artist.attachment_cover.download_url_original}
|
||||||
|
],
|
||||||
|
"link": [{"value": channel.get_absolute_url()}],
|
||||||
|
"atom:link": [
|
||||||
|
{
|
||||||
|
"href": channel.get_rss_url(),
|
||||||
|
"rel": "self",
|
||||||
|
"type": "application/rss+xml",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert serializers.rss_serialize_channel(channel) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_full_channel(factories):
|
||||||
|
channel = factories["audio.Channel"]()
|
||||||
|
upload1 = factories["music.Upload"](playable=True)
|
||||||
|
upload2 = factories["music.Upload"](playable=True)
|
||||||
|
|
||||||
|
expected = serializers.rss_serialize_channel(channel)
|
||||||
|
expected["item"] = [
|
||||||
|
serializers.rss_serialize_item(upload1),
|
||||||
|
serializers.rss_serialize_item(upload2),
|
||||||
|
]
|
||||||
|
expected = {"channel": expected}
|
||||||
|
|
||||||
|
result = serializers.rss_serialize_channel_full(
|
||||||
|
channel=channel, uploads=[upload1, upload2]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"seconds, expected",
|
||||||
|
[
|
||||||
|
(0, "00:00:00"),
|
||||||
|
(None, "00:00:00"),
|
||||||
|
(61, "00:01:01"),
|
||||||
|
(3601, "01:00:01"),
|
||||||
|
(7345, "02:02:25"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rss_duration(seconds, expected):
|
||||||
|
assert serializers.rss_duration(seconds) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dt, expected",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
datetime.datetime(2020, 1, 30, 6, 0, 49, tzinfo=pytz.UTC),
|
||||||
|
"Thu, 30 Jan 2020 06:00:49 +0000",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rss_date(dt, expected):
|
||||||
|
assert serializers.rss_date(dt) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_metadata_serializer_validation():
|
||||||
|
payload = {
|
||||||
|
"language": "fr",
|
||||||
|
"copyright": "Me",
|
||||||
|
"owner_email": "contact@me.com",
|
||||||
|
"owner_name": "Me",
|
||||||
|
"itunes_category": "Health & Fitness",
|
||||||
|
"itunes_subcategory": "Sexuality",
|
||||||
|
"unknown_key": "noop",
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = serializers.ChannelMetadataSerializer(data=payload)
|
||||||
|
|
||||||
|
assert serializer.is_valid(raise_exception=True) is True
|
||||||
|
|
||||||
|
payload.pop("unknown_key")
|
||||||
|
|
||||||
|
assert serializer.validated_data == payload
|
||||||
|
|
|
@ -33,6 +33,13 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings):
|
||||||
"type": "application/activity+json",
|
"type": "application/activity+json",
|
||||||
"href": channel.actor.fid,
|
"href": channel.actor.fid,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"tag": "link",
|
||||||
|
"rel": "alternate",
|
||||||
|
"type": "application/rss+xml",
|
||||||
|
"href": channel.get_rss_url(),
|
||||||
|
"title": "{} - RSS Podcast Feed".format(channel.artist.name),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"tag": "link",
|
"tag": "link",
|
||||||
"rel": "alternate",
|
"rel": "alternate",
|
||||||
|
|
|
@ -15,6 +15,7 @@ def test_channel_create(logged_in_api_client):
|
||||||
"description": {"text": "This is my channel", "content_type": "text/markdown"},
|
"description": {"text": "This is my channel", "content_type": "text/markdown"},
|
||||||
"tags": ["hello", "world"],
|
"tags": ["hello", "world"],
|
||||||
"content_category": "podcast",
|
"content_category": "podcast",
|
||||||
|
"metadata": {"language": "en", "itunes_category": "Sports"},
|
||||||
}
|
}
|
||||||
|
|
||||||
url = reverse("api:v1:channels-list")
|
url = reverse("api:v1:channels-list")
|
||||||
|
@ -192,3 +193,21 @@ def test_subscriptions_all(factories, logged_in_api_client):
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data == {"results": [subscription.uuid], "count": 1}
|
assert response.data == {"results": [subscription.uuid], "count": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_rss_feed(factories, api_client):
|
||||||
|
channel = factories["audio.Channel"]()
|
||||||
|
upload1 = factories["music.Upload"](library=channel.library, playable=True)
|
||||||
|
upload2 = factories["music.Upload"](library=channel.library, playable=True)
|
||||||
|
|
||||||
|
expected = serializers.rss_serialize_channel_full(
|
||||||
|
channel=channel, uploads=[upload2, upload1]
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
|
||||||
|
|
||||||
|
response = api_client.get(url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.data == expected
|
||||||
|
assert response["Content-Type"] == "application/rss+xml"
|
||||||
|
|
|
@ -85,3 +85,19 @@ def test_removing_obj_removes_content(factories):
|
||||||
removed_content.refresh_from_db()
|
removed_content.refresh_from_db()
|
||||||
|
|
||||||
kept_content.refresh_from_db()
|
kept_content.refresh_from_db()
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_as_plain_text(factories):
|
||||||
|
content = factories["common.Content"](
|
||||||
|
content_type="text/html", text="<b>hello world</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert content.as_plain_text == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_truncate(factories):
|
||||||
|
content = factories["common.Content"](
|
||||||
|
content_type="text/html", text="<b>hello world</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert content.truncate(5) == "hello…"
|
||||||
|
|
|
@ -16,8 +16,8 @@ exposedbydefault = false
|
||||||
[entryPoints]
|
[entryPoints]
|
||||||
[entryPoints.http]
|
[entryPoints.http]
|
||||||
address = ":80"
|
address = ":80"
|
||||||
[entryPoints.http.redirect]
|
# [entryPoints.http.redirect]
|
||||||
entryPoint = "https"
|
entryPoint = "http"
|
||||||
[entryPoints.https]
|
[entryPoints.https]
|
||||||
address = ":443"
|
address = ":443"
|
||||||
[entryPoints.https.tls]
|
[entryPoints.https.tls]
|
||||||
|
|
Loading…
Reference in New Issue