From 37885ada0b4c2449ebb90027496669c57f44c36f Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 19 Mar 2020 09:43:46 +0100 Subject: [PATCH] See #170: API for OPML export --- api/funkwhale_api/audio/serializers.py | 23 ++++++++++-- api/funkwhale_api/audio/views.py | 15 ++++++++ api/tests/audio/test_serializers.py | 50 ++++++++++++++++++++++++-- api/tests/audio/test_views.py | 18 ++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py index c2b5a04b1..e99ad868e 100644 --- a/api/funkwhale_api/audio/serializers.py +++ b/api/funkwhale_api/audio/serializers.py @@ -792,7 +792,7 @@ class RssFeedItemSerializer(serializers.Serializer): return upload -def rss_date(dt): +def rfc822_date(dt): return dt.strftime("%a, %d %b %Y %H:%M:%S %z") @@ -814,7 +814,7 @@ def rss_serialize_item(upload): "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)}], + "pubDate": [{"value": rfc822_date(upload.creation_date)}], "itunes:duration": [{"value": rss_duration(upload.duration)}], "itunes:explicit": [{"value": "no"}], "itunes:episodeType": [{"value": "full"}], @@ -921,3 +921,22 @@ 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} + + +# OPML stuff +def get_opml_outline(channel): + return { + "title": channel.artist.name, + "text": channel.artist.name, + "type": "rss", + "xmlUrl": channel.get_rss_url(), + "htmlUrl": channel.actor.url, + } + + +def get_opml(channels, date, title): + return { + "version": "2.0", + "head": [{"date": [{"value": rfc822_date(date)}], "title": [{"value": title}]}], + "body": [{"outline": [get_opml_outline(channel) for channel in channels]}], + } diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py index eb6b9d001..01e29c659 100644 --- a/api/funkwhale_api/audio/views.py +++ b/api/funkwhale_api/audio/views.py @@ -8,6 +8,7 @@ from rest_framework import viewsets from django import http from django.db import transaction from django.db.models import Count, Prefetch, Q +from django.utils import timezone from funkwhale_api.common import locales from funkwhale_api.common import permissions @@ -93,6 +94,20 @@ class ChannelViewSet( def perform_create(self, serializer): return serializer.save(attributed_to=self.request.user.actor) + def list(self, request, *args, **kwargs): + if self.request.GET.get("output") == "opml": + queryset = self.filter_queryset(self.get_queryset())[:500] + opml = serializers.get_opml( + channels=queryset, + date=timezone.now(), + title="Funkwhale channels OPML export", + ) + xml_body = renderers.render_xml(renderers.dict_to_xml_tree("opml", opml)) + return http.HttpResponse(xml_body, content_type="application/xml") + + else: + return super().list(request, *args, **kwargs) + @decorators.action( detail=True, methods=["post"], diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py index 1b647389a..f5672d982 100644 --- a/api/tests/audio/test_serializers.py +++ b/api/tests/audio/test_serializers.py @@ -303,7 +303,7 @@ def test_rss_item_serializer(factories): "itunes:summary": [{"cdata_value": description.rendered}], "description": [{"value": description.as_plain_text}], "guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}], - "pubDate": [{"value": serializers.rss_date(upload.creation_date)}], + "pubDate": [{"value": serializers.rfc822_date(upload.creation_date)}], "itunes:duration": [{"value": serializers.rss_duration(upload.duration)}], "itunes:keywords": [{"value": "pop rock"}], "itunes:explicit": [{"value": "no"}], @@ -456,8 +456,8 @@ def test_rss_duration(seconds, expected): ), ], ) -def test_rss_date(dt, expected): - assert serializers.rss_date(dt) == expected +def test_rfc822_date(dt, expected): + assert serializers.rfc822_date(dt) == expected def test_channel_metadata_serializer_validation(): @@ -915,3 +915,47 @@ def test_get_channel_from_rss_honor_mrf_inbox_after_http( apply.assert_any_call({"id": rss_url}) apply.assert_any_call({"id": final_rss_url}) apply.assert_any_call({"id": public_url}) + + +def test_opml_serializer(factories, now): + channels = [ + factories["audio.Channel"](), + factories["audio.Channel"](), + factories["audio.Channel"](), + ] + + title = "Hello world" + expected = { + "version": "2.0", + "head": [ + { + "date": [{"value": serializers.rfc822_date(now)}], + "title": [{"value": title}], + } + ], + "body": [ + { + "outline": [ + serializers.get_opml_outline(channels[0]), + serializers.get_opml_outline(channels[1]), + serializers.get_opml_outline(channels[2]), + ], + } + ], + } + + assert serializers.get_opml(channels=channels, date=now, title=title) == expected + + +def test_opml_outline_serializer(factories, now): + channel = factories["audio.Channel"]() + + expected = { + "title": channel.artist.name, + "text": channel.artist.name, + "type": "rss", + "xmlUrl": channel.get_rss_url(), + "htmlUrl": channel.actor.url, + } + + assert serializers.get_opml_outline(channel) == expected diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py index 6d6e52f45..b28b44416 100644 --- a/api/tests/audio/test_views.py +++ b/api/tests/audio/test_views.py @@ -4,6 +4,7 @@ import pytest from django.urls import reverse from funkwhale_api.audio import categories +from funkwhale_api.audio import renderers from funkwhale_api.audio import serializers from funkwhale_api.audio import views from funkwhale_api.common import locales @@ -89,6 +90,23 @@ def test_channel_list(factories, logged_in_api_client): } +def test_channel_list_opml(factories, logged_in_api_client, now): + channel1 = factories["audio.Channel"]() + channel2 = factories["audio.Channel"]() + expected_xml = serializers.get_opml( + channels=[channel2, channel1], title="Funkwhale channels OPML export", date=now + ) + expected_content = renderers.render_xml( + renderers.dict_to_xml_tree("opml", expected_xml) + ) + url = reverse("api:v1:channels-list") + response = logged_in_api_client.get(url, {"output": "opml"}) + + assert response.status_code == 200 + assert response.content == expected_content + assert response["content-type"] == "application/xml" + + def test_channel_update(logged_in_api_client, factories): actor = logged_in_api_client.user.create_actor() channel = factories["audio.Channel"](attributed_to=actor)