See #170: RSS feeds for channels

merge-requests/1042/head
Eliot Berriot 2020-01-30 17:28:52 +01:00
rodzic a04b0b706b
commit 9c22a72ed1
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6B501DFD73514E14
18 zmienionych plików z 923 dodań i 6 usunięć

Wyświetl plik

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

Wyświetl plik

@ -25,6 +25,7 @@ class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
music_factories.ArtistFactory,
attributed_to=factory.SelfAttribute("..attributed_to"),
)
metadata = factory.LazyAttribute(lambda o: {})
class Meta:
model = "audio.Channel"

Wyświetl plik

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

Wyświetl plik

@ -1,14 +1,22 @@
import uuid
from django.contrib.postgres.fields import JSONField
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.federation import keys
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
def empty_dict():
return {}
class Channel(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
artist = models.OneToOneField(
@ -29,6 +37,19 @@ class Channel(models.Model):
)
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):
actor_data = user_models.get_actor_data(username, **kwargs)

Wyświetl plik

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

Wyświetl plik

@ -4,15 +4,50 @@ 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"])
@ -21,6 +56,17 @@ class ChannelCreateSerializer(serializers.Serializer):
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):
@ -38,7 +84,9 @@ class ChannelCreateSerializer(serializers.Serializer):
tags_models.set_tags(artist, *validated_data["tags"])
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
channel.actor = models.generate_actor(
@ -57,6 +105,9 @@ class ChannelCreateSerializer(serializers.Serializer):
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)
@ -64,6 +115,32 @@ class ChannelUpdateSerializer(serializers.Serializer):
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):
@ -72,6 +149,9 @@ class ChannelUpdateSerializer(serializers.Serializer):
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"]
@ -111,7 +191,14 @@ class ChannelSerializer(serializers.ModelSerializer):
class Meta:
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):
return music_serializers.serialize_artist_simple(obj.artist)
@ -136,3 +223,129 @@ class SubscriptionSerializer(serializers.Serializer):
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}

Wyświetl plik

@ -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():
metas.append(
{

Wyświetl plik

@ -6,14 +6,17 @@ from rest_framework import response
from rest_framework import viewsets
from django import http
from django.db.models import Prefetch
from django.db.utils import IntegrityError
from funkwhale_api.common import permissions
from funkwhale_api.common import preferences
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 . import filters, models, serializers
from . import filters, models, renderers, serializers
class ChannelsMixin(object):
@ -37,7 +40,17 @@ class ChannelViewSet(
serializer_class = serializers.ChannelSerializer
queryset = (
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")
)
permission_classes = [
@ -92,6 +105,30 @@ class ChannelViewSet(
request.user.actor.emitted_follows.filter(target=object.actor).delete()
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):
context = super().get_serializer_context()
context["subscriptions_count"] = self.action in ["retrieve", "create", "update"]

Wyświetl plik

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

Wyświetl plik

@ -301,6 +301,20 @@ class Content(models.Model):
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)
def warm_attachment_thumbnails(sender, instance, **kwargs):

Wyświetl plik

@ -291,6 +291,10 @@ def render_html(text, content_type):
return clean_html(rendered).strip().replace("\n", "")
def render_plain_text(html):
return bleach.clean(html, tags=[], strip=True)
@transaction.atomic
def attach_content(obj, field, content_data):
from . import models

Wyświetl plik

@ -322,6 +322,10 @@ class Actor(models.Model):
"https://{}/".format(domain)
)
@property
def display_name(self):
return self.name or self.preferred_username
FETCH_STATUSES = [
("pending", "Pending"),

Wyświetl plik

@ -55,6 +55,8 @@ def dict_to_xml_tree(root_tag, d, parent=None):
else:
if key == "value":
root.text = str(value)
elif key == "cdata_value":
root.text = "<![CDATA[{}]]>".format(str(value))
else:
root.set(key, str(value))
return root

Wyświetl plik

@ -1,7 +1,13 @@
import datetime
import pytest
import pytz
from funkwhale_api.audio import serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
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
@ -43,6 +49,26 @@ def test_channel_serializer_create(factories):
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):
channel = factories["audio.Channel"](artist__set_tags=["rock"])
@ -74,6 +100,27 @@ def test_channel_serializer_update(factories):
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):
content = factories["common.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(
channel.attributed_to
).data,
"metadata": {},
}
expected["artist"]["description"] = common_serializers.ContentSerializer(
content
@ -115,3 +163,169 @@ def test_subscription_serializer(factories, to_api_date):
}
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

Wyświetl plik

@ -33,6 +33,13 @@ def test_library_artist(spa_html, no_api_auth, client, factories, settings):
"type": "application/activity+json",
"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",
"rel": "alternate",

Wyświetl plik

@ -15,6 +15,7 @@ def test_channel_create(logged_in_api_client):
"description": {"text": "This is my channel", "content_type": "text/markdown"},
"tags": ["hello", "world"],
"content_category": "podcast",
"metadata": {"language": "en", "itunes_category": "Sports"},
}
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.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"

Wyświetl plik

@ -85,3 +85,19 @@ def test_removing_obj_removes_content(factories):
removed_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…"

Wyświetl plik

@ -16,8 +16,8 @@ exposedbydefault = false
[entryPoints]
[entryPoints.http]
address = ":80"
[entryPoints.http.redirect]
entryPoint = "https"
# [entryPoints.http.redirect]
entryPoint = "http"
[entryPoints.https]
address = ":443"
[entryPoints.https.tls]