From deb1f3577960fb74a64a1a2fc85c4cd26e064908 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Fri, 13 Mar 2020 12:16:51 +0100
Subject: [PATCH] =?UTF-8?q?See=20#170:=20subscribe=20to=203d-party=20RSS?=
 =?UTF-8?q?=C2=A0feeds=20in=20Funkwhale?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 api/config/settings/common.py            |  13 +
 api/funkwhale_api/audio/categories.py    |   2 +
 api/funkwhale_api/audio/factories.py     |  17 +
 api/funkwhale_api/audio/models.py        |  22 +-
 api/funkwhale_api/audio/renderers.py     |  10 +-
 api/funkwhale_api/audio/serializers.py   | 496 ++++++++++++++++++++++-
 api/funkwhale_api/audio/tasks.py         |  51 +++
 api/funkwhale_api/audio/views.py         |  71 +++-
 api/funkwhale_api/common/utils.py        |  20 +
 api/funkwhale_api/common/views.py        |   4 +
 api/funkwhale_api/federation/actors.py   |  17 +-
 api/funkwhale_api/federation/tasks.py    |   1 +
 api/funkwhale_api/federation/views.py    |  15 +-
 api/funkwhale_api/music/admin.py         |   7 +
 api/funkwhale_api/music/models.py        |  26 +-
 api/requirements/base.txt                |   1 +
 api/setup.cfg                            |   1 +
 api/tests/audio/test_serializers.py      | 469 ++++++++++++++++++++-
 api/tests/audio/test_spa_views.py        |   2 +-
 api/tests/audio/test_tasks.py            |  53 +++
 api/tests/audio/test_views.py            |  75 ++++
 api/tests/conftest.py                    |   2 +
 api/tests/federation/test_serializers.py |   2 +-
 api/tests/federation/test_views.py       |  12 +
 api/tests/music/test_filters.py          |   4 +-
 front/src/components/audio/SearchBar.vue |  24 +-
 front/src/router/index.js                |   3 +-
 front/src/views/Search.vue               | 154 +++----
 front/src/views/channels/DetailBase.vue  |   6 +-
 29 files changed, 1451 insertions(+), 129 deletions(-)
 create mode 100644 api/funkwhale_api/audio/tasks.py
 create mode 100644 api/tests/audio/test_tasks.py

diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 23d90cb6d6..8af05491fd 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -580,6 +580,11 @@ CELERY_BROKER_URL = env(
 CELERY_TASK_DEFAULT_RATE_LIMIT = 1
 CELERY_TASK_TIME_LIMIT = 300
 CELERY_BEAT_SCHEDULE = {
+    "audio.fetch_rss_feeds": {
+        "task": "audio.fetch_rss_feeds",
+        "schedule": crontab(minute="0", hour="*"),
+        "options": {"expires": 60 * 60},
+    },
     "common.prune_unattached_attachments": {
         "task": "common.prune_unattached_attachments",
         "schedule": crontab(minute="0", hour="*"),
@@ -976,3 +981,11 @@ MIN_DELAY_BETWEEN_DOWNLOADS_COUNT = env.int(
 MARKDOWN_EXTENSIONS = env.list("MARKDOWN_EXTENSIONS", default=["nl2br", "extra"])
 
 LINKIFIER_SUPPORTED_TLDS = ["audio"] + env.list("LINKINFIER_SUPPORTED_TLDS", default=[])
+EXTERNAL_MEDIA_PROXY_ENABLED = env.bool("EXTERNAL_MEDIA_PROXY_ENABLED", default=True)
+
+# By default, only people who subscribe to a podcast RSS will have access to it
+# switch to "instance" or "everyone" to change that
+PODCASTS_THIRD_PARTY_VISIBILITY = env("PODCASTS_THIRD_PARTY_VISIBILITY", default="me")
+PODCASTS_RSS_FEED_REFRESH_DELAY = env.int(
+    "PODCASTS_RSS_FEED_REFRESH_DELAY", default=60 * 60 * 24
+)
diff --git a/api/funkwhale_api/audio/categories.py b/api/funkwhale_api/audio/categories.py
index 56a748a537..a026425fc7 100644
--- a/api/funkwhale_api/audio/categories.py
+++ b/api/funkwhale_api/audio/categories.py
@@ -109,3 +109,5 @@ ITUNES_CATEGORIES = {
         "TV Reviews",
     ],
 }
+
+ITUNES_SUBCATEGORIES = [s for p in ITUNES_CATEGORIES.values() for s in p]
diff --git a/api/funkwhale_api/audio/factories.py b/api/funkwhale_api/audio/factories.py
index 6a7c567452..7e2a4bfaed 100644
--- a/api/funkwhale_api/audio/factories.py
+++ b/api/funkwhale_api/audio/factories.py
@@ -1,6 +1,9 @@
+import uuid
+
 import factory
 
 from funkwhale_api.factories import registry, NoUpdateOnCreate
+from funkwhale_api.federation import actors
 from funkwhale_api.federation import factories as federation_factories
 from funkwhale_api.music import factories as music_factories
 
@@ -11,6 +14,10 @@ def set_actor(o):
     return models.generate_actor(str(o.uuid))
 
 
+def get_rss_channel_name():
+    return "rssfeed-{}".format(uuid.uuid4())
+
+
 @registry.register
 class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
     uuid = factory.Faker("uuid4")
@@ -32,10 +39,20 @@ class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
         model = "audio.Channel"
 
     class Params:
+        external = factory.Trait(
+            attributed_to=factory.LazyFunction(actors.get_service_actor),
+            library__privacy_level="me",
+            actor=factory.SubFactory(
+                federation_factories.ActorFactory,
+                local=True,
+                preferred_username=factory.LazyFunction(get_rss_channel_name),
+            ),
+        )
         local = factory.Trait(
             attributed_to=factory.SubFactory(
                 federation_factories.ActorFactory, local=True
             ),
+            library__privacy_level="everyone",
             artist__local=True,
         )
 
diff --git a/api/funkwhale_api/audio/models.py b/api/funkwhale_api/audio/models.py
index bdf700f786..38e023c4a7 100644
--- a/api/funkwhale_api/audio/models.py
+++ b/api/funkwhale_api/audio/models.py
@@ -19,6 +19,19 @@ def empty_dict():
     return {}
 
 
+class ChannelQuerySet(models.QuerySet):
+    def external_rss(self, include=True):
+        from funkwhale_api.federation import actors
+
+        query = models.Q(
+            attributed_to=actors.get_service_actor(),
+            actor__preferred_username__startswith="rssfeed-",
+        )
+        if include:
+            return self.filter(query)
+        return self.exclude(query)
+
+
 class Channel(models.Model):
     uuid = models.UUIDField(default=uuid.uuid4, unique=True)
     artist = models.OneToOneField(
@@ -45,6 +58,8 @@ class Channel(models.Model):
         default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
     )
 
+    objects = ChannelQuerySet.as_manager()
+
     def get_absolute_url(self):
         suffix = self.uuid
         if self.actor.is_local:
@@ -54,7 +69,9 @@ class Channel(models.Model):
         return federation_utils.full_url("/channels/{}".format(suffix))
 
     def get_rss_url(self):
-        if not self.artist.is_local:
+        if not self.artist.is_local or self.actor.preferred_username.startswith(
+            "rssfeed-"
+        ):
             return self.rss_url
 
         return federation_utils.full_url(
@@ -81,5 +98,6 @@ def generate_actor(username, **kwargs):
 @receiver(post_delete, sender=Channel)
 def delete_channel_related_objs(instance, **kwargs):
     instance.library.delete()
-    instance.actor.delete()
+    if instance.actor != instance.attributed_to:
+        instance.actor.delete()
     instance.artist.delete()
diff --git a/api/funkwhale_api/audio/renderers.py b/api/funkwhale_api/audio/renderers.py
index 0a8e71d6b0..c5c49eccec 100644
--- a/api/funkwhale_api/audio/renderers.py
+++ b/api/funkwhale_api/audio/renderers.py
@@ -21,12 +21,16 @@ class PodcastRSSRenderer(renderers.JSONRenderer):
         }
         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"
-        )
+        return render_xml(tree)
 
 
 class PodcastRSSContentNegociation(negotiation.DefaultContentNegotiation):
     def select_renderer(self, request, renderers, format_suffix=None):
 
         return (PodcastRSSRenderer(), PodcastRSSRenderer.media_type)
+
+
+def render_xml(tree):
+    return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
+        tree, encoding="utf-8"
+    )
diff --git a/api/funkwhale_api/audio/serializers.py b/api/funkwhale_api/audio/serializers.py
index 4a093ad8b0..2f6b76442f 100644
--- a/api/funkwhale_api/audio/serializers.py
+++ b/api/funkwhale_api/audio/serializers.py
@@ -1,17 +1,32 @@
+import datetime
+import logging
+import time
+import uuid
+
 from django.conf import settings
 from django.db import transaction
+from django.db.models import Q
+from django.utils import timezone
+
+import feedparser
+import requests
+import pytz
 
 from rest_framework import serializers
 
 from django.templatetags.static import static
+from django.urls import reverse
 
 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.common import preferences
+from funkwhale_api.common import session
+from funkwhale_api.federation import actors
 from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import serializers as federation_serializers
 from funkwhale_api.federation import utils as federation_utils
+from funkwhale_api.moderation import mrf
 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
@@ -22,6 +37,9 @@ from . import categories
 from . import models
 
 
+logger = logging.getLogger(__name__)
+
+
 class ChannelMetadataSerializer(serializers.Serializer):
     itunes_category = serializers.ChoiceField(
         choices=categories.ITUNES_CATEGORIES, required=True
@@ -218,7 +236,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
 
 class ChannelSerializer(serializers.ModelSerializer):
     artist = serializers.SerializerMethodField()
-    actor = federation_serializers.APIActorSerializer()
+    actor = serializers.SerializerMethodField()
     attributed_to = federation_serializers.APIActorSerializer()
     rss_url = serializers.CharField(source="get_rss_url")
 
@@ -246,6 +264,11 @@ class ChannelSerializer(serializers.ModelSerializer):
     def get_subscriptions_count(self, obj):
         return obj.actor.received_follows.exclude(approved=False).count()
 
+    def get_actor(self, obj):
+        if obj.attributed_to == actors.get_service_actor():
+            return None
+        return federation_serializers.APIActorSerializer(obj.actor).data
+
 
 class SubscriptionSerializer(serializers.Serializer):
     approved = serializers.BooleanField(read_only=True)
@@ -259,11 +282,475 @@ class SubscriptionSerializer(serializers.Serializer):
         return data
 
 
+class RssSubscribeSerializer(serializers.Serializer):
+    url = serializers.URLField()
+
+
+class FeedFetchException(Exception):
+    pass
+
+
+class BlockedFeedException(FeedFetchException):
+    pass
+
+
+def retrieve_feed(url):
+    try:
+        logger.info("Fetching RSS feed at %s", url)
+        response = session.get_session().get(url)
+        response.raise_for_status()
+    except requests.exceptions.HTTPError as e:
+        if e.response:
+            raise FeedFetchException(
+                "Error while fetching feed: HTTP {}".format(e.response.status_code)
+            )
+        raise FeedFetchException("Error while fetching feed: unknown error")
+    except requests.exceptions.Timeout:
+        raise FeedFetchException("Error while fetching feed: timeout")
+    except requests.exceptions.ConnectionError:
+        raise FeedFetchException("Error while fetching feed: connection error")
+    except requests.RequestException as e:
+        raise FeedFetchException("Error while fetching feed: {}".format(e))
+    except Exception as e:
+        raise FeedFetchException("Error while fetching feed: {}".format(e))
+
+    return response
+
+
+@transaction.atomic
+def get_channel_from_rss_url(url):
+    # first, check if the url is blocked
+    is_valid, _ = mrf.inbox.apply({"id": url})
+    if not is_valid:
+        logger.warn("Feed fetch for url %s dropped by MRF", url)
+        raise BlockedFeedException("This feed or domain is blocked")
+
+    # retrieve the XML payload at the given URL
+    response = retrieve_feed(url)
+
+    parsed_feed = feedparser.parse(response.text)
+    serializer = RssFeedSerializer(data=parsed_feed["feed"])
+    if not serializer.is_valid():
+        raise FeedFetchException("Invalid xml content: {}".format(serializer.errors))
+
+    # second mrf check with validated data
+    urls_to_check = set()
+    atom_link = serializer.validated_data.get("atom_link")
+
+    if atom_link and atom_link != url:
+        urls_to_check.add(atom_link)
+
+    if serializer.validated_data["link"] != url:
+        urls_to_check.add(serializer.validated_data["link"])
+
+    for u in urls_to_check:
+        is_valid, _ = mrf.inbox.apply({"id": u})
+        if not is_valid:
+            logger.warn("Feed fetch for url %s dropped by MRF", u)
+            raise BlockedFeedException("This feed or domain is blocked")
+
+    # now, we're clear, we can save the data
+    channel = serializer.save(rss_url=url)
+
+    entries = parsed_feed.entries or []
+    uploads = []
+    track_defaults = {}
+    existing_uploads = list(
+        channel.library.uploads.all().select_related(
+            "track__description", "track__attachment_cover"
+        )
+    )
+    if parsed_feed.feed.rights:
+        track_defaults["copyright"] = parsed_feed.feed.rights
+    for entry in entries:
+        logger.debug("Importing feed item %s", entry.id)
+        s = RssFeedItemSerializer(data=entry)
+        if not s.is_valid():
+            logger.debug("Skipping invalid RSS feed item %s", entry)
+            continue
+        uploads.append(
+            s.save(channel, existing_uploads=existing_uploads, **track_defaults)
+        )
+
+    common_utils.on_commit(
+        music_models.TrackActor.create_entries,
+        library=channel.library,
+        delete_existing=True,
+    )
+
+    return channel, uploads
+
+
 # RSS related stuff
 # https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
 # is extremely useful
 
 
+class RssFeedSerializer(serializers.Serializer):
+    title = serializers.CharField()
+    link = serializers.URLField()
+    language = serializers.CharField(required=False, allow_blank=True)
+    rights = serializers.CharField(required=False, allow_blank=True)
+    itunes_explicit = serializers.BooleanField(required=False, allow_null=True)
+    tags = serializers.ListField(required=False)
+    atom_link = serializers.DictField(required=False)
+    summary_detail = serializers.DictField(required=False)
+    author_detail = serializers.DictField(required=False)
+    image = serializers.DictField(required=False)
+
+    def validate_atom_link(self, v):
+        if (
+            v.get("rel", "self") == "self"
+            and v.get("type", "application/rss+xml") == "application/rss+xml"
+        ):
+            return v["href"]
+
+    def validate_summary_detail(self, v):
+        content = v.get("value")
+        if not content:
+            return
+        return {
+            "content_type": v.get("type", "text/plain"),
+            "text": content,
+        }
+
+    def validate_image(self, v):
+        url = v.get("href")
+        if url:
+            return {
+                "url": url,
+                "mimetype": common_utils.get_mimetype_from_ext(url) or "image/jpeg",
+            }
+
+    def validate_tags(self, v):
+        data = {}
+        for row in v:
+            if row.get("scheme") != "http://www.itunes.com/":
+                continue
+            term = row["term"]
+            if "parent" not in data and term in categories.ITUNES_CATEGORIES:
+                data["parent"] = term
+            elif "child" not in data and term in categories.ITUNES_SUBCATEGORIES:
+                data["child"] = term
+            elif (
+                term not in categories.ITUNES_SUBCATEGORIES
+                and term not in categories.ITUNES_CATEGORIES
+            ):
+                raw_tags = term.split(" ")
+                data["tags"] = []
+                tag_serializer = tags_serializers.TagNameField()
+                for tag in raw_tags:
+                    try:
+                        data["tags"].append(tag_serializer.to_internal_value(tag))
+                    except Exception:
+                        pass
+
+        return data
+
+    @transaction.atomic
+    def save(self, rss_url):
+        validated_data = self.validated_data
+        # because there may be redirections from the original feed URL
+        real_rss_url = validated_data.get("atom_link", rss_url) or rss_url
+        service_actor = actors.get_service_actor()
+        author = validated_data.get("author_detail", {})
+        categories = validated_data.get("tags", {})
+        metadata = {
+            "explicit": validated_data.get("itunes_explicit", False),
+            "copyright": validated_data.get("rights"),
+            "owner_name": author.get("name"),
+            "owner_email": author.get("email"),
+            "itunes_category": categories.get("parent"),
+            "itunes_subcategory": categories.get("child"),
+            "language": validated_data.get("language"),
+        }
+        public_url = validated_data["link"]
+        existing = (
+            models.Channel.objects.external_rss()
+            .filter(
+                Q(rss_url=real_rss_url) | Q(rss_url=rss_url) | Q(actor__url=public_url)
+            )
+            .first()
+        )
+        channel_defaults = {
+            "rss_url": real_rss_url,
+            "metadata": metadata,
+        }
+        if existing:
+            artist_kwargs = {"channel": existing}
+            actor_kwargs = {"channel": existing}
+            actor_defaults = {"url": public_url}
+        else:
+            artist_kwargs = {"pk": None}
+            actor_kwargs = {"pk": None}
+            preferred_username = "rssfeed-{}".format(uuid.uuid4())
+            actor_defaults = {
+                "preferred_username": preferred_username,
+                "type": "Application",
+                "domain": service_actor.domain,
+                "url": public_url,
+                "fid": federation_utils.full_url(
+                    reverse(
+                        "federation:actors-detail",
+                        kwargs={"preferred_username": preferred_username},
+                    )
+                ),
+            }
+            channel_defaults["attributed_to"] = service_actor
+
+        actor_defaults["last_fetch_date"] = timezone.now()
+
+        # create/update the artist profile
+        artist, created = music_models.Artist.objects.update_or_create(
+            **artist_kwargs,
+            defaults={
+                "attributed_to": service_actor,
+                "name": validated_data["title"],
+                "content_category": "podcast",
+            },
+        )
+
+        cover = validated_data.get("image")
+
+        if cover:
+            common_utils.attach_file(artist, "attachment_cover", cover)
+        tags = categories.get("tags", [])
+
+        if tags:
+            tags_models.set_tags(artist, *tags)
+
+        summary = validated_data.get("summary_detail")
+        if summary:
+            common_utils.attach_content(artist, "description", summary)
+
+        if created:
+            channel_defaults["artist"] = artist
+
+        # create/update the actor
+        actor, created = federation_models.Actor.objects.update_or_create(
+            **actor_kwargs, defaults=actor_defaults
+        )
+        if created:
+            channel_defaults["actor"] = actor
+
+        # create the library
+        if not existing:
+            channel_defaults["library"] = music_models.Library.objects.create(
+                actor=service_actor,
+                privacy_level=settings.PODCASTS_THIRD_PARTY_VISIBILITY,
+                name=actor_defaults["preferred_username"],
+            )
+
+        # create/update the channel
+        channel, created = models.Channel.objects.update_or_create(
+            pk=existing.pk if existing else None, defaults=channel_defaults,
+        )
+        return channel
+
+
+class ItunesDurationField(serializers.CharField):
+    def to_internal_value(self, v):
+        try:
+            return int(v)
+        except (ValueError, TypeError):
+            pass
+        parts = v.split(":")
+        int_parts = []
+        for part in parts:
+            try:
+                int_parts.append(int(part))
+            except (ValueError, TypeError):
+                raise serializers.ValidationError("Invalid duration {}".format(v))
+
+        if len(int_parts) == 2:
+            hours = 0
+            minutes, seconds = int_parts
+        elif len(int_parts) == 3:
+            hours, minutes, seconds = int_parts
+        else:
+            raise serializers.ValidationError("Invalid duration {}".format(v))
+
+        return (hours * 3600) + (minutes * 60) + seconds
+
+
+class DummyField(serializers.Field):
+    def to_internal_value(self, v):
+        return v
+
+
+def get_cached_upload(uploads, expected_track_uuid):
+    for upload in uploads:
+        if upload.track.uuid == expected_track_uuid:
+            return upload
+
+
+class RssFeedItemSerializer(serializers.Serializer):
+    id = serializers.CharField()
+    title = serializers.CharField()
+    rights = serializers.CharField(required=False, allow_blank=True)
+    itunes_season = serializers.IntegerField(required=False)
+    itunes_episode = serializers.IntegerField(required=False)
+    itunes_duration = ItunesDurationField()
+    links = serializers.ListField()
+    tags = serializers.ListField(required=False)
+    summary_detail = serializers.DictField(required=False)
+    published_parsed = DummyField(required=False)
+    image = serializers.DictField(required=False)
+
+    def validate_summary_detail(self, v):
+        content = v.get("value")
+        if not content:
+            return
+        return {
+            "content_type": v.get("type", "text/plain"),
+            "text": content,
+        }
+
+    def validate_image(self, v):
+        url = v.get("href")
+        if url:
+            return {
+                "url": url,
+                "mimetype": common_utils.get_mimetype_from_ext(url) or "image/jpeg",
+            }
+
+    def validate_links(self, v):
+        data = {}
+        for row in v:
+            if not row.get("type", "").startswith("audio/"):
+                continue
+            if row.get("rel") != "enclosure":
+                continue
+            try:
+                size = int(row.get("length"))
+            except (TypeError, ValueError):
+                raise serializers.ValidationError("Invalid size")
+
+            data["audio"] = {
+                "mimetype": row["type"],
+                "size": size,
+                "source": row["href"],
+            }
+
+        if not data:
+            raise serializers.ValidationError("No valid audio enclosure found")
+
+        return data
+
+    def validate_tags(self, v):
+        data = {}
+        for row in v:
+            if row.get("scheme") != "http://www.itunes.com/":
+                continue
+            term = row["term"]
+            raw_tags = term.split(" ")
+            data["tags"] = []
+            tag_serializer = tags_serializers.TagNameField()
+            for tag in raw_tags:
+                try:
+                    data["tags"].append(tag_serializer.to_internal_value(tag))
+                except Exception:
+                    pass
+
+        return data
+
+    @transaction.atomic
+    def save(self, channel, existing_uploads=[], **track_defaults):
+        validated_data = self.validated_data
+        categories = validated_data.get("tags", {})
+        expected_uuid = uuid.uuid3(
+            uuid.NAMESPACE_URL, "rss://{}-{}".format(channel.pk, validated_data["id"])
+        )
+        existing_upload = get_cached_upload(existing_uploads, expected_uuid)
+        if existing_upload:
+            existing_track = existing_upload.track
+        else:
+            existing_track = (
+                music_models.Track.objects.filter(
+                    uuid=expected_uuid, artist__channel=channel
+                )
+                .select_related("description", "attachment_cover")
+                .first()
+            )
+            if existing_track:
+                existing_upload = existing_track.uploads.filter(
+                    library=channel.library
+                ).first()
+
+        track_defaults = track_defaults
+        track_defaults.update(
+            {
+                "disc_number": validated_data.get("itunes_season", 1),
+                "position": validated_data.get("itunes_episode", 1),
+                "title": validated_data["title"],
+                "artist": channel.artist,
+            }
+        )
+        if "rights" in validated_data:
+            track_defaults["rights"] = validated_data["rights"]
+
+        if "published_parsed" in validated_data:
+            track_defaults["creation_date"] = datetime.datetime.fromtimestamp(
+                time.mktime(validated_data["published_parsed"])
+            ).replace(tzinfo=pytz.utc)
+
+        upload_defaults = {
+            "source": validated_data["links"]["audio"]["source"],
+            "size": validated_data["links"]["audio"]["size"],
+            "mimetype": validated_data["links"]["audio"]["mimetype"],
+            "duration": validated_data["itunes_duration"],
+            "import_status": "finished",
+            "library": channel.library,
+        }
+        if existing_track:
+            track_kwargs = {"pk": existing_track.pk}
+            upload_kwargs = {"track": existing_track}
+        else:
+            track_kwargs = {"pk": None}
+            track_defaults["uuid"] = expected_uuid
+            upload_kwargs = {"pk": None}
+
+        if existing_upload and existing_upload.source != upload_defaults["source"]:
+            # delete existing upload, the url to the audio file has changed
+            existing_upload.delete()
+
+        # create/update the track
+        track, created = music_models.Track.objects.update_or_create(
+            **track_kwargs, defaults=track_defaults,
+        )
+        # optimisation for reducing SQL queries, because we cannot use select_related with
+        # update or create, so we restore the cache by hand
+        if existing_track:
+            for field in ["attachment_cover", "description"]:
+                cached_id_value = getattr(existing_track, "{}_id".format(field))
+                new_id_value = getattr(track, "{}_id".format(field))
+                if new_id_value and cached_id_value == new_id_value:
+                    setattr(track, field, getattr(existing_track, field))
+
+        cover = validated_data.get("image")
+
+        if cover:
+            common_utils.attach_file(track, "attachment_cover", cover)
+        tags = categories.get("tags", [])
+
+        if tags:
+            tags_models.set_tags(track, *tags)
+
+        summary = validated_data.get("summary_detail")
+        if summary:
+            common_utils.attach_content(track, "description", summary)
+
+        if created:
+            upload_defaults["track"] = track
+
+        # create/update the upload
+        upload, created = music_models.Upload.objects.update_or_create(
+            **upload_kwargs, defaults=upload_defaults
+        )
+
+        return upload
+
+
 def rss_date(dt):
     return dt.strftime("%a, %d %b %Y %H:%M:%S %z")
 
@@ -344,7 +831,12 @@ def rss_serialize_channel(channel):
                 "href": channel.get_rss_url(),
                 "rel": "self",
                 "type": "application/rss+xml",
-            }
+            },
+            {
+                "href": channel.actor.fid,
+                "rel": "alternate",
+                "type": "application/activity+json",
+            },
         ],
     }
     if language:
diff --git a/api/funkwhale_api/audio/tasks.py b/api/funkwhale_api/audio/tasks.py
new file mode 100644
index 0000000000..96e2163382
--- /dev/null
+++ b/api/funkwhale_api/audio/tasks.py
@@ -0,0 +1,51 @@
+import datetime
+import logging
+
+from django.conf import settings
+from django.db import transaction
+from django.utils import timezone
+
+from funkwhale_api.taskapp import celery
+
+from . import models
+from . import serializers
+
+logger = logging.getLogger(__name__)
+
+
+@celery.app.task(name="audio.fetch_rss_feeds")
+def fetch_rss_feeds():
+    limit = timezone.now() - datetime.timedelta(
+        seconds=settings.PODCASTS_RSS_FEED_REFRESH_DELAY
+    )
+    candidates = (
+        models.Channel.objects.external_rss()
+        .filter(actor__last_fetch_date__lte=limit)
+        .values_list("rss_url", flat=True)
+    )
+
+    total = len(candidates)
+    logger.info("Refreshing %s rss feeds…", total)
+    for url in candidates:
+        fetch_rss_feed.delay(rss_url=url)
+
+
+@celery.app.task(name="audio.fetch_rss_feed")
+@transaction.atomic
+def fetch_rss_feed(rss_url):
+    channel = (
+        models.Channel.objects.external_rss()
+        .filter(rss_url=rss_url)
+        .order_by("id")
+        .first()
+    )
+    if not channel:
+        logger.warn("Cannot refresh non external feed")
+        return
+
+    try:
+        serializers.get_channel_from_rss_url(rss_url)
+    except serializers.BlockedFeedException:
+        # channel was blocked since last fetch, let's delete it
+        logger.info("Deleting blocked channel linked to %s", rss_url)
+        channel.delete()
diff --git a/api/funkwhale_api/audio/views.py b/api/funkwhale_api/audio/views.py
index 974797c353..eb6b9d001e 100644
--- a/api/funkwhale_api/audio/views.py
+++ b/api/funkwhale_api/audio/views.py
@@ -8,12 +8,12 @@ from rest_framework import viewsets
 from django import http
 from django.db import transaction
 from django.db.models import Count, Prefetch, Q
-from django.db.utils import IntegrityError
 
 from funkwhale_api.common import locales
 from funkwhale_api.common import permissions
 from funkwhale_api.common import preferences
 from funkwhale_api.common.mixins import MultipleLookupDetailMixin
+from funkwhale_api.federation import actors
 from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import routes
 from funkwhale_api.federation import utils as federation_utils
@@ -100,17 +100,19 @@ class ChannelViewSet(
     )
     def subscribe(self, request, *args, **kwargs):
         object = self.get_object()
-        subscription = federation_models.Follow(
-            target=object.actor, approved=True, actor=request.user.actor,
-        )
+        subscription = federation_models.Follow(actor=request.user.actor)
         subscription.fid = subscription.get_federation_id()
-        try:
-            subscription.save()
-        except IntegrityError:
-            # there's already a subscription for this actor/channel
-            subscription = object.actor.received_follows.filter(
-                actor=request.user.actor
-            ).get()
+        subscription, created = SubscriptionsViewSet.queryset.get_or_create(
+            target=object.actor,
+            actor=request.user.actor,
+            defaults={
+                "approved": True,
+                "fid": subscription.fid,
+                "uuid": subscription.uuid,
+            },
+        )
+        # prefetch stuff
+        subscription = SubscriptionsViewSet.queryset.get(pk=subscription.pk)
 
         data = serializers.SubscriptionSerializer(subscription).data
         return response.Response(data, status=201)
@@ -135,6 +137,10 @@ class ChannelViewSet(
         if not object.attributed_to.is_local:
             return response.Response({"detail": "Not found"}, status=404)
 
+        if object.attributed_to == actors.get_service_actor():
+            # external feed, we redirect to the canonical one
+            return http.HttpResponseRedirect(object.rss_url)
+
         uploads = (
             object.library.uploads.playable_by(None)
             .prefetch_related(
@@ -170,6 +176,49 @@ class ChannelViewSet(
         }
         return response.Response(data)
 
+    @decorators.action(
+        methods=["post"],
+        detail=False,
+        url_path="rss-subscribe",
+        url_name="rss_subscribe",
+    )
+    @transaction.atomic
+    def rss_subscribe(self, request, *args, **kwargs):
+        serializer = serializers.RssSubscribeSerializer(data=request.data)
+        if not serializer.is_valid():
+            return response.Response(serializer.errors, status=400)
+        channel = (
+            models.Channel.objects.filter(rss_url=serializer.validated_data["url"],)
+            .order_by("id")
+            .first()
+        )
+        if not channel:
+            # try to retrieve the channel via its URL and create it
+            try:
+                channel, uploads = serializers.get_channel_from_rss_url(
+                    serializer.validated_data["url"]
+                )
+            except serializers.FeedFetchException as e:
+                return response.Response({"detail": str(e)}, status=400,)
+
+        subscription = federation_models.Follow(actor=request.user.actor)
+        subscription.fid = subscription.get_federation_id()
+        subscription, created = SubscriptionsViewSet.queryset.get_or_create(
+            target=channel.actor,
+            actor=request.user.actor,
+            defaults={
+                "approved": True,
+                "fid": subscription.fid,
+                "uuid": subscription.uuid,
+            },
+        )
+        # prefetch stuff
+        subscription = SubscriptionsViewSet.queryset.get(pk=subscription.pk)
+
+        return response.Response(
+            serializers.SubscriptionSerializer(subscription).data, status=201
+        )
+
     def get_serializer_context(self):
         context = super().get_serializer_context()
         context["subscriptions_count"] = self.action in [
diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py
index 34b1dc0069..49719e168e 100644
--- a/api/funkwhale_api/common/utils.py
+++ b/api/funkwhale_api/common/utils.py
@@ -310,13 +310,21 @@ def render_plain_text(html):
     return bleach.clean(html, tags=[], strip=True)
 
 
+def same_content(old, text=None, content_type=None):
+    return old.text == text and old.content_type == content_type
+
+
 @transaction.atomic
 def attach_content(obj, field, content_data):
     from . import models
 
+    content_data = content_data or {}
     existing = getattr(obj, "{}_id".format(field))
 
     if existing:
+        if same_content(getattr(obj, field), **content_data):
+            # optimization to avoid a delete/save if possible
+            return getattr(obj, field)
         getattr(obj, field).delete()
         setattr(obj, field, None)
 
@@ -376,3 +384,15 @@ def attach_file(obj, field, file_data, fetch=False):
     setattr(obj, field, attachment)
     obj.save(update_fields=[field])
     return attachment
+
+
+def get_mimetype_from_ext(path):
+    parts = path.lower().split(".")
+    ext = parts[-1]
+    match = {
+        "jpeg": "image/jpeg",
+        "jpg": "image/jpeg",
+        "png": "image/png",
+        "gif": "image/gif",
+    }
+    return match.get(ext)
diff --git a/api/funkwhale_api/common/views.py b/api/funkwhale_api/common/views.py
index 05cb025c39..1766ba1272 100644
--- a/api/funkwhale_api/common/views.py
+++ b/api/funkwhale_api/common/views.py
@@ -163,6 +163,10 @@ class AttachmentViewSet(
     @transaction.atomic
     def proxy(self, request, *args, **kwargs):
         instance = self.get_object()
+        if not settings.EXTERNAL_MEDIA_PROXY_ENABLED:
+            r = response.Response(status=302)
+            r["Location"] = instance.url
+            return r
 
         size = request.GET.get("next", "original").lower()
         if size not in ["original", "medium_square_crop"]:
diff --git a/api/funkwhale_api/federation/actors.py b/api/funkwhale_api/federation/actors.py
index 39161a9cb6..187bd8c95c 100644
--- a/api/funkwhale_api/federation/actors.py
+++ b/api/funkwhale_api/federation/actors.py
@@ -42,21 +42,32 @@ def get_actor(fid, skip_cache=False):
     return serializer.save(last_fetch_date=timezone.now())
 
 
-def get_service_actor():
+_CACHE = {}
+
+
+def get_service_actor(cache=True):
+    if cache and "service_actor" in _CACHE:
+        return _CACHE["service_actor"]
+
     name, domain = (
         settings.FEDERATION_SERVICE_ACTOR_USERNAME,
         settings.FEDERATION_HOSTNAME,
     )
     try:
-        return models.Actor.objects.select_related().get(
+        actor = models.Actor.objects.select_related().get(
             preferred_username=name, domain__name=domain
         )
     except models.Actor.DoesNotExist:
         pass
+    else:
+        _CACHE["service_actor"] = actor
+        return actor
 
     args = users_models.get_actor_data(name)
     private, public = keys.get_key_pair()
     args["private_key"] = private.decode("utf-8")
     args["public_key"] = public.decode("utf-8")
     args["type"] = "Service"
-    return models.Actor.objects.create(**args)
+    actor = models.Actor.objects.create(**args)
+    _CACHE["service_actor"] = actor
+    return actor
diff --git a/api/funkwhale_api/federation/tasks.py b/api/funkwhale_api/federation/tasks.py
index 8cd0c04398..04457e1fcb 100644
--- a/api/funkwhale_api/federation/tasks.py
+++ b/api/funkwhale_api/federation/tasks.py
@@ -311,6 +311,7 @@ def fetch(fetch_obj):
         auth = signing.get_auth(actor.private_key, actor.private_key_id)
     else:
         auth = None
+    auth = None
     try:
         if url.startswith("webfinger://"):
             # we first grab the correpsonding webfinger representation
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 93977dcd26..7a16fbed4e 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -13,7 +13,16 @@ from funkwhale_api.moderation import models as moderation_models
 from funkwhale_api.music import models as music_models
 from funkwhale_api.music import utils as music_utils
 
-from . import activity, authentication, models, renderers, serializers, utils, webfinger
+from . import (
+    actors,
+    activity,
+    authentication,
+    models,
+    renderers,
+    serializers,
+    utils,
+    webfinger,
+)
 
 
 def redirect_to_html(public_url):
@@ -61,6 +70,10 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
     queryset = models.Actor.objects.local().select_related("user")
     serializer_class = serializers.ActorSerializer
 
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        return queryset.exclude(channel__attributed_to=actors.get_service_actor())
+
     def retrieve(self, request, *args, **kwargs):
         instance = self.get_object()
         if utils.should_redirect_ap_to_html(request.headers.get("accept")):
diff --git a/api/funkwhale_api/music/admin.py b/api/funkwhale_api/music/admin.py
index 584653ab9a..56712746d4 100644
--- a/api/funkwhale_api/music/admin.py
+++ b/api/funkwhale_api/music/admin.py
@@ -23,6 +23,13 @@ class TrackAdmin(admin.ModelAdmin):
     list_select_related = ["album__artist", "artist"]
 
 
+@admin.register(models.TrackActor)
+class TrackActorAdmin(admin.ModelAdmin):
+    list_display = ["actor", "track", "upload", "internal"]
+    search_fields = ["actor__preferred_username", "track__name"]
+    list_select_related = ["actor", "track"]
+
+
 @admin.register(models.ImportBatch)
 class ImportBatchAdmin(admin.ModelAdmin):
     list_display = ["submitted_by", "creation_date", "import_request", "status"]
diff --git a/api/funkwhale_api/music/models.py b/api/funkwhale_api/music/models.py
index e0adfe86b4..c7ae71ca8d 100644
--- a/api/funkwhale_api/music/models.py
+++ b/api/funkwhale_api/music/models.py
@@ -786,9 +786,13 @@ class Upload(models.Model):
         with remote_response as r:
             remote_response.raise_for_status()
             extension = utils.get_ext_from_type(self.mimetype)
-            title = " - ".join(
-                [self.track.title, self.track.album.title, self.track.artist.name]
-            )
+            title_parts = []
+            title_parts.append(self.track.title)
+            if self.track.album:
+                title_parts.append(self.track.album.title)
+            title_parts.append(self.track.artist.name)
+
+            title = " - ".join(title_parts)
             filename = "{}.{}".format(title, extension)
             tmp_file = tempfile.TemporaryFile()
             for chunk in r.iter_content(chunk_size=512):
@@ -1126,7 +1130,7 @@ class LibraryQuerySet(models.QuerySet):
         )
 
     def viewable_by(self, actor):
-        from funkwhale_api.federation.models import LibraryFollow
+        from funkwhale_api.federation.models import LibraryFollow, Follow
 
         if actor is None:
             return self.filter(privacy_level="everyone")
@@ -1136,11 +1140,17 @@ class LibraryQuerySet(models.QuerySet):
         followed_libraries = LibraryFollow.objects.filter(
             actor=actor, approved=True
         ).values_list("target", flat=True)
+        followed_channels_libraries = (
+            Follow.objects.exclude(target__channel=None)
+            .filter(actor=actor, approved=True,)
+            .values_list("target__channel__library", flat=True)
+        )
         return self.filter(
             me_query
             | instance_query
             | models.Q(privacy_level="everyone")
             | models.Q(pk__in=followed_libraries)
+            | models.Q(pk__in=followed_channels_libraries)
         )
 
 
@@ -1174,7 +1184,7 @@ class Library(federation_models.FederationMixin):
         return "/library/{}".format(self.uuid)
 
     def save(self, **kwargs):
-        if not self.pk and not self.fid and self.actor.get_user():
+        if not self.pk and not self.fid and self.actor.is_local:
             self.fid = self.get_federation_id()
             self.followers_url = self.fid + "/followers"
 
@@ -1266,7 +1276,11 @@ class TrackActor(models.Model):
         ).values_list("id", "track")
         objs = []
         if library.privacy_level == "me":
-            follow_queryset = library.received_follows.filter(approved=True).exclude(
+            if library.get_channel():
+                follow_queryset = library.channel.actor.received_follows
+            else:
+                follow_queryset = library.received_follows
+            follow_queryset = follow_queryset.filter(approved=True).exclude(
                 actor__user__isnull=True
             )
             if actor_ids:
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index 9053bdd3a8..e2a21df291 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -79,3 +79,4 @@ click>=7,<8
 service_identity==18.1.0
 markdown>=3.2,<4
 bleach>=3,<4
+feedparser==6.0.0b3
diff --git a/api/setup.cfg b/api/setup.cfg
index 581396c37a..2b8f8e8258 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -31,3 +31,4 @@ env =
     FUNKWHALE_SPA_HTML_ROOT=http://noop/
     PROXY_MEDIA=true
     MUSIC_USE_DENORMALIZATION=true
+    EXTERNAL_MEDIA_PROXY_ENABLED=true
diff --git a/api/tests/audio/test_serializers.py b/api/tests/audio/test_serializers.py
index 7f2bc77a65..add1068885 100644
--- a/api/tests/audio/test_serializers.py
+++ b/api/tests/audio/test_serializers.py
@@ -1,5 +1,7 @@
 import datetime
+import uuid
 
+import feedparser
 import pytest
 import pytz
 
@@ -8,6 +10,7 @@ from django.templatetags.static import static
 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 actors
 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
@@ -232,6 +235,28 @@ def test_channel_serializer_representation(factories, to_api_date):
     assert serializers.ChannelSerializer(channel).data == expected
 
 
+def test_channel_serializer_external_representation(factories, to_api_date):
+    content = factories["common.Content"]()
+    channel = factories["audio.Channel"](artist__description=content, external=True)
+
+    expected = {
+        "artist": music_serializers.serialize_artist_simple(channel.artist),
+        "uuid": str(channel.uuid),
+        "creation_date": to_api_date(channel.creation_date),
+        "actor": None,
+        "attributed_to": federation_serializers.APIActorSerializer(
+            channel.attributed_to
+        ).data,
+        "metadata": {},
+        "rss_url": channel.get_rss_url(),
+    }
+    expected["artist"]["description"] = common_serializers.ContentSerializer(
+        content
+    ).data
+
+    assert serializers.ChannelSerializer(channel).data == expected
+
+
 def test_channel_serializer_representation_subscriptions_count(factories, to_api_date):
     channel = factories["audio.Channel"]()
     factories["federation.Follow"](target=channel.actor)
@@ -351,7 +376,12 @@ def test_rss_channel_serializer(factories):
                 "href": channel.get_rss_url(),
                 "rel": "self",
                 "type": "application/rss+xml",
-            }
+            },
+            {
+                "href": channel.actor.fid,
+                "rel": "alternate",
+                "type": "application/activity+json",
+            },
         ],
     }
 
@@ -446,3 +476,440 @@ def test_channel_metadata_serializer_validation():
     payload.pop("unknown_key")
 
     assert serializer.validated_data == payload
+
+
+def test_rss_feed_serializer_create(db, now):
+    rss_url = "http://example.rss/"
+
+    xml_payload = """<?xml version="1.0" encoding="UTF-8"?>
+        <rss version="2.0">
+            <channel>
+                <title>Hello</title>
+                <description>Description</description>
+                <link>http://public.url</link>
+                <atom:link rel="self" type="application/rss+xml" href="http://real.rss.url"/>
+                <lastBuildDate>Wed, 11 Mar 2020 16:01:08 GMT</lastBuildDate>
+                <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                <ttl>30</ttl>
+                <language>en</language>
+                <copyright>2019 Tests</copyright>
+                <itunes:keywords>pop rock</itunes:keywords>
+                <image>
+                    <url>
+                        https://image.url
+                    </url>
+                    <title>Image caption</title>
+                </image>
+                <itunes:image href="https://image.url"/>
+                <itunes:subtitle>Subtitle</itunes:subtitle>
+                <itunes:type>episodic</itunes:type>
+                <itunes:author>Author</itunes:author>
+                <itunes:summary><![CDATA[Some content]]></itunes:summary>
+                <itunes:owner>
+                    <itunes:name>Name</itunes:name>
+                    <itunes:email>email@domain</itunes:email>
+                </itunes:owner>
+                <itunes:explicit>yes</itunes:explicit>
+                <itunes:keywords/>
+                <itunes:category text="Business">
+                    <itunes:category text="Entrepreneurship">
+                </itunes:category>
+            </channel>
+        </rss>
+    """
+    parsed_feed = feedparser.parse(xml_payload)
+    serializer = serializers.RssFeedSerializer(data=parsed_feed.feed)
+
+    assert serializer.is_valid(raise_exception=True) is True
+
+    channel = serializer.save(rss_url)
+
+    assert channel.rss_url == "http://real.rss.url"
+    assert channel.attributed_to == actors.get_service_actor()
+    assert channel.library.actor == actors.get_service_actor()
+    assert channel.artist.name == "Hello"
+    assert channel.artist.attributed_to == actors.get_service_actor()
+    assert channel.artist.description.content_type == "text/plain"
+    assert channel.artist.description.text == "Some content"
+    assert channel.artist.attachment_cover.url == "https://image.url"
+    assert channel.artist.get_tags() == ["pop", "rock"]
+    assert channel.actor.url == "http://public.url"
+    assert channel.actor.last_fetch_date == now
+    assert channel.metadata == {
+        "explicit": True,
+        "copyright": "2019 Tests",
+        "owner_name": "Name",
+        "owner_email": "email@domain",
+        "itunes_category": "Business",
+        "itunes_subcategory": "Entrepreneurship",
+        "language": "en",
+    }
+
+
+def test_rss_feed_serializer_update(factories, now):
+    rss_url = "http://example.rss/"
+    channel = factories["audio.Channel"](rss_url=rss_url, external=True)
+
+    xml_payload = """<?xml version="1.0" encoding="UTF-8"?>
+        <rss version="2.0">
+            <channel>
+                <title>Hello</title>
+                <description>Description</description>
+                <link>http://public.url</link>
+                <atom:link rel="self" type="application/rss+xml" href="http://real.rss.url"/>
+                <lastBuildDate>Wed, 11 Mar 2020 16:01:08 GMT</lastBuildDate>
+                <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                <ttl>30</ttl>
+                <language>en</language>
+                <copyright>2019 Tests</copyright>
+                <itunes:keywords>pop rock</itunes:keywords>
+                <image>
+                    <url>
+                        https://image.url
+                    </url>
+                    <title>Image caption</title>
+                </image>
+                <itunes:image href="https://image.url"/>
+                <itunes:subtitle>Subtitle</itunes:subtitle>
+                <itunes:type>episodic</itunes:type>
+                <itunes:author>Author</itunes:author>
+                <itunes:summary><![CDATA[Some content]]></itunes:summary>
+                <itunes:owner>
+                    <itunes:name>Name</itunes:name>
+                    <itunes:email>email@domain</itunes:email>
+                </itunes:owner>
+                <itunes:explicit>yes</itunes:explicit>
+                <itunes:keywords/>
+                <itunes:category text="Business">
+                    <itunes:category text="Entrepreneurship">
+                </itunes:category>
+            </channel>
+        </rss>
+    """
+    parsed_feed = feedparser.parse(xml_payload)
+    serializer = serializers.RssFeedSerializer(data=parsed_feed.feed)
+
+    assert serializer.is_valid(raise_exception=True) is True
+
+    serializer.save(rss_url)
+
+    channel.refresh_from_db()
+
+    assert channel.rss_url == "http://real.rss.url"
+    assert channel.attributed_to == actors.get_service_actor()
+    assert channel.library.actor == actors.get_service_actor()
+    assert channel.library.fid is not None
+    assert channel.artist.name == "Hello"
+    assert channel.artist.attributed_to == actors.get_service_actor()
+    assert channel.artist.description.content_type == "text/plain"
+    assert channel.artist.description.text == "Some content"
+    assert channel.artist.attachment_cover.url == "https://image.url"
+    assert channel.artist.get_tags() == ["pop", "rock"]
+    assert channel.actor.url == "http://public.url"
+    assert channel.actor.last_fetch_date == now
+    assert channel.metadata == {
+        "explicit": True,
+        "copyright": "2019 Tests",
+        "owner_name": "Name",
+        "owner_email": "email@domain",
+        "itunes_category": "Business",
+        "itunes_subcategory": "Entrepreneurship",
+        "language": "en",
+    }
+
+
+def test_rss_feed_item_serializer_create(factories):
+    rss_url = "http://example.rss/"
+    channel = factories["audio.Channel"](rss_url=rss_url, external=True)
+
+    xml_payload = """<?xml version="1.0" encoding="UTF-8"?>
+        <rss version="2.0">
+            <channel>
+                <title>Hello</title>
+                <description>Description</description>
+                <link>http://public.url</link>
+                <atom:link rel="self" type="application/rss+xml" href="http://real.rss.url"/>
+                <item>
+                    <title>Episode 33</title>
+                    <itunes:subtitle>Subtitle</itunes:subtitle>
+                    <itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
+                    <guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-9101-2746218c4f32]]></guid>
+                    <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                    <itunes:duration>00:22:37</itunes:duration>
+                    <itunes:keywords>pop rock</itunes:keywords>
+                    <itunes:season>2</itunes:season>
+                    <itunes:episode>33</itunes:episode>
+                    <itunes:image href="https://image.url/" />
+                    <description><![CDATA[Html content]]></description>
+                    <link>http://public.url/</link>
+                    <enclosure url="https://file.domain/audio.mp3" length="54315884" type="audio/mpeg"/>
+                </item>
+            </channel>
+        </rss>
+    """
+    parsed_feed = feedparser.parse(xml_payload)
+    entry = parsed_feed.entries[0]
+    serializer = serializers.RssFeedItemSerializer(data=entry)
+
+    assert serializer.is_valid(raise_exception=True) is True
+
+    upload = serializer.save(channel, copyright="test something")
+
+    expected_uuid = uuid.uuid3(
+        uuid.NAMESPACE_URL,
+        "rss://{}-16f66fff-41ae-4a1c-9101-2746218c4f32".format(channel.pk),
+    )
+    assert upload.library == channel.library
+    assert upload.import_status == "finished"
+    assert upload.source == "https://file.domain/audio.mp3"
+    assert upload.size == 54315884
+    assert upload.duration == 1357
+    assert upload.mimetype == "audio/mpeg"
+    assert upload.track.uuid == expected_uuid
+    assert upload.track.artist == channel.artist
+    assert upload.track.copyright == "test something"
+    assert upload.track.position == 33
+    assert upload.track.disc_number == 2
+    assert upload.track.creation_date == datetime.datetime(2020, 3, 11, 16).replace(
+        tzinfo=pytz.utc
+    )
+    assert upload.track.get_tags() == ["pop", "rock"]
+    assert upload.track.attachment_cover.url == "https://image.url/"
+    assert upload.track.description.text == "<p>Html content</p>"
+    assert upload.track.description.content_type == "text/html"
+
+
+def test_rss_feed_item_serializer_update(factories):
+    rss_url = "http://example.rss/"
+    channel = factories["audio.Channel"](rss_url=rss_url, external=True)
+    expected_uuid = uuid.uuid3(
+        uuid.NAMESPACE_URL,
+        "rss://{}-16f66fff-41ae-4a1c-9101-2746218c4f32".format(channel.pk),
+    )
+    upload = factories["music.Upload"](
+        track__uuid=expected_uuid,
+        source="https://file.domain/audio.mp3",
+        library=channel.library,
+        track__artist=channel.artist,
+    )
+    track = upload.track
+
+    xml_payload = """<?xml version="1.0" encoding="UTF-8"?>
+        <rss version="2.0">
+            <channel>
+                <title>Hello</title>
+                <description>Description</description>
+                <link>http://public.url</link>
+                <atom:link rel="self" type="application/rss+xml" href="http://real.rss.url"/>
+                <item>
+                    <title>Episode 33</title>
+                    <itunes:subtitle>Subtitle</itunes:subtitle>
+                    <itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
+                    <guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-9101-2746218c4f32]]></guid>
+                    <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                    <itunes:duration>00:22:37</itunes:duration>
+                    <itunes:keywords>pop rock</itunes:keywords>
+                    <itunes:season>2</itunes:season>
+                    <itunes:episode>33</itunes:episode>
+                    <itunes:image href="https://image.url/" />
+                    <description><![CDATA[Html content]]></description>
+                    <link>http://public.url/</link>
+                    <enclosure url="https://file.domain/audio.mp3" length="54315884" type="audio/mpeg"/>
+                </item>
+            </channel>
+        </rss>
+    """
+    parsed_feed = feedparser.parse(xml_payload)
+    entry = parsed_feed.entries[0]
+    serializer = serializers.RssFeedItemSerializer(data=entry)
+
+    assert serializer.is_valid(raise_exception=True) is True
+
+    serializer.save(channel, copyright="test something")
+    upload.refresh_from_db()
+
+    assert upload.track == track
+    assert upload.library == channel.library
+    assert upload.import_status == "finished"
+    assert upload.source == "https://file.domain/audio.mp3"
+    assert upload.size == 54315884
+    assert upload.duration == 1357
+    assert upload.mimetype == "audio/mpeg"
+    assert upload.track.uuid == expected_uuid
+    assert upload.track.artist == channel.artist
+    assert upload.track.copyright == "test something"
+    assert upload.track.position == 33
+    assert upload.track.disc_number == 2
+    assert upload.track.creation_date == datetime.datetime(2020, 3, 11, 16).replace(
+        tzinfo=pytz.utc
+    )
+    assert upload.track.get_tags() == ["pop", "rock"]
+    assert upload.track.attachment_cover.url == "https://image.url/"
+    assert upload.track.description.text == "<p>Html content</p>"
+    assert upload.track.description.content_type == "text/html"
+
+
+def test_get_channel_from_rss_url(db, r_mock, mocker):
+    rss_url = "http://example.rss/"
+    xml_payload = """<?xml version="1.0" encoding="UTF-8"?>
+        <rss version="2.0">
+            <channel>
+                <title>Hello</title>
+                <description>Description</description>
+                <link>http://public.url</link>
+                <atom:link rel="self" type="application/rss+xml" href="http://real.rss.url"/>
+                <lastBuildDate>Wed, 11 Mar 2020 16:01:08 GMT</lastBuildDate>
+                <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                <ttl>30</ttl>
+                <language>en</language>
+                <copyright>2019 Tests</copyright>
+                <itunes:keywords>pop rock</itunes:keywords>
+                <image>
+                    <url>
+                        https://image.url
+                    </url>
+                    <title>Image caption</title>
+                </image>
+                <itunes:image href="https://image.url"/>
+                <itunes:subtitle>Subtitle</itunes:subtitle>
+                <itunes:type>episodic</itunes:type>
+                <itunes:author>Author</itunes:author>
+                <itunes:summary><![CDATA[Some content]]></itunes:summary>
+                <itunes:owner>
+                    <itunes:name>Name</itunes:name>
+                    <itunes:email>email@domain</itunes:email>
+                </itunes:owner>
+                <itunes:explicit>yes</itunes:explicit>
+                <itunes:keywords/>
+                <itunes:category text="Business">
+                    <itunes:category text="Entrepreneurship">
+                </itunes:category>
+                <item>
+                    <title>Episode 33</title>
+                    <itunes:subtitle>Subtitle</itunes:subtitle>
+                    <itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
+                    <guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-9101-2746218c4f32]]></guid>
+                    <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                    <itunes:duration>00:22:37</itunes:duration>
+                    <itunes:keywords>pop rock</itunes:keywords>
+                    <itunes:season>2</itunes:season>
+                    <itunes:episode>33</itunes:episode>
+                    <itunes:image href="https://image.url/" />
+                    <description><![CDATA[Html content]]></description>
+                    <link>http://public.url/</link>
+                    <enclosure url="https://file.domain/audio.mp3" length="54315884" type="audio/mpeg"/>
+                </item>
+                <item>
+                    <title>Episode 32</title>
+                    <itunes:subtitle>Subtitle</itunes:subtitle>
+                    <itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
+                    <guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-910e-2746218c4f32]]></guid>
+                    <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                    <itunes:duration>00:22:37</itunes:duration>
+                    <itunes:keywords>pop rock</itunes:keywords>
+                    <itunes:season>2</itunes:season>
+                    <itunes:episode>32</itunes:episode>
+                    <itunes:image href="https://image.url/" />
+                    <description><![CDATA[Html content]]></description>
+                    <link>http://public.url/</link>
+                    <enclosure url="https://file.domain/audio2.mp3" length="54315884" type="audio/mpeg"/>
+                </item>
+                <item>
+                    <title>Ignored, missing enĉlosure</title>
+                    <itunes:subtitle>Subtitle</itunes:subtitle>
+                    <itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
+                    <guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-910e-2746218c4f32]]></guid>
+                    <pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
+                    <itunes:duration>00:22:37</itunes:duration>
+                    <itunes:keywords>pop rock</itunes:keywords>
+                    <itunes:season>2</itunes:season>
+                    <itunes:episode>32</itunes:episode>
+                    <itunes:image href="https://image.url/" />
+                    <description><![CDATA[Html content]]></description>
+                    <link>http://public.url/</link>
+                </item>
+            </channel>
+        </rss>
+    """
+    parsed_feed = feedparser.parse(xml_payload)
+
+    r_mock.get(rss_url, text=xml_payload)
+
+    feed_init = mocker.spy(serializers.RssFeedSerializer, "__init__")
+    feed_save = mocker.spy(serializers.RssFeedSerializer, "save")
+    item_init = mocker.spy(serializers.RssFeedItemSerializer, "__init__")
+    item_save = mocker.spy(serializers.RssFeedItemSerializer, "save")
+    on_commit = mocker.spy(common_utils, "on_commit")
+    channel, uploads = serializers.get_channel_from_rss_url(rss_url)
+
+    assert channel.artist.name == "Hello"
+
+    serializer_instance = feed_init.call_args[0][0]
+    feed_init.assert_called_once_with(serializer_instance, data=parsed_feed.feed)
+    feed_save.assert_called_once_with(serializer_instance, rss_url)
+
+    for i in [0, 1]:
+        serializer_instance = item_init.call_args_list[i][0][0]
+        item_init.assert_any_call(serializer_instance, data=parsed_feed.entries[i])
+        item_save.assert_any_call(
+            serializer_instance, channel, existing_uploads=[], copyright="2019 Tests"
+        )
+
+    assert len(uploads) == 2
+    assert channel.library.uploads.count() == 2
+
+    on_commit.assert_any_call(
+        serializers.music_models.TrackActor.create_entries,
+        library=channel.library,
+        delete_existing=True,
+    )
+
+
+def test_get_channel_from_rss_honor_mrf_inbox_before_http(
+    mrf_inbox_registry, factories, mocker
+):
+    apply = mocker.patch.object(mrf_inbox_registry, "apply", return_value=(None, False))
+    rss_url = "https://rss.domain/test"
+
+    with pytest.raises(serializers.FeedFetchException, match=r".*blocked.*"):
+        serializers.get_channel_from_rss_url(rss_url)
+
+    apply.assert_any_call({"id": rss_url})
+
+
+def test_get_channel_from_rss_honor_mrf_inbox_after_http(
+    mrf_inbox_registry, r_mock, mocker, db
+):
+    apply = mocker.patch.object(
+        mrf_inbox_registry,
+        "apply",
+        side_effect=[(True, False), (True, False), (None, False)],
+    )
+    rss_url = "https://rss.domain/test"
+    # the feed has a redirection, we check both urls
+    final_rss_url = "https://real.rss.domain/test"
+    public_url = "http://public.url"
+    xml_payload = """<?xml version="1.0" encoding="UTF-8"?>
+        <rss version="2.0">
+            <channel>
+                <title>Hello</title>
+                <description>Description</description>
+                <link>{}</link>
+                <atom:link rel="self" type="application/rss+xml" href="{}"/>
+                <language>en</language>
+                <copyright>2019 Tests</copyright>
+                <itunes:keywords>pop rock</itunes:keywords>
+            </channel>
+        </rss>
+    """.format(
+        public_url, final_rss_url
+    )
+
+    r_mock.get(rss_url, text=xml_payload)
+
+    with pytest.raises(serializers.FeedFetchException, match=r".*blocked.*"):
+        serializers.get_channel_from_rss_url(rss_url)
+
+    apply.assert_any_call({"id": rss_url})
+    apply.assert_any_call({"id": final_rss_url})
+    apply.assert_any_call({"id": public_url})
diff --git a/api/tests/audio/test_spa_views.py b/api/tests/audio/test_spa_views.py
index 265aea0a38..ebce5d301a 100644
--- a/api/tests/audio/test_spa_views.py
+++ b/api/tests/audio/test_spa_views.py
@@ -11,7 +11,7 @@ from funkwhale_api.music import serializers
 
 @pytest.mark.parametrize("attribute", ["uuid", "actor.full_username"])
 def test_channel_detail(attribute, spa_html, no_api_auth, client, factories, settings):
-    channel = factories["audio.Channel"]()
+    channel = factories["audio.Channel"](library__privacy_level="everyone")
     factories["music.Upload"](playable=True, library=channel.library)
     url = "/channels/{}".format(utils.recursive_getattr(channel, attribute))
     detail_url = "/channels/{}".format(channel.actor.full_username)
diff --git a/api/tests/audio/test_tasks.py b/api/tests/audio/test_tasks.py
new file mode 100644
index 0000000000..e96e9b763e
--- /dev/null
+++ b/api/tests/audio/test_tasks.py
@@ -0,0 +1,53 @@
+import datetime
+
+import pytest
+
+from funkwhale_api.audio import tasks
+
+
+def test_fetch_rss_feeds(factories, settings, now, mocker):
+    settings.PODCASTS_RSS_FEED_REFRESH_DELAY = 5
+    prunable_date = now - datetime.timedelta(
+        seconds=settings.PODCASTS_RSS_FEED_REFRESH_DELAY
+    )
+    fetch_rss_feed = mocker.patch.object(tasks.fetch_rss_feed, "delay")
+    channels = [
+        # recent, not fetched
+        factories["audio.Channel"](actor__last_fetch_date=now, external=True),
+        # old but not external, not fetched
+        factories["audio.Channel"](actor__last_fetch_date=prunable_date),
+        # old and external, fetched !
+        factories["audio.Channel"](actor__last_fetch_date=prunable_date, external=True),
+        factories["audio.Channel"](actor__last_fetch_date=prunable_date, external=True),
+    ]
+
+    tasks.fetch_rss_feeds()
+
+    assert fetch_rss_feed.call_count == 2
+    fetch_rss_feed.assert_any_call(rss_url=channels[2].rss_url)
+    fetch_rss_feed.assert_any_call(rss_url=channels[3].rss_url)
+
+
+def test_fetch_rss_feed(factories, mocker):
+    channel = factories["audio.Channel"](external=True)
+
+    get_channel_from_rss_url = mocker.patch.object(
+        tasks.serializers, "get_channel_from_rss_url"
+    )
+    tasks.fetch_rss_feed(channel.rss_url)
+
+    get_channel_from_rss_url.assert_called_once_with(channel.rss_url)
+
+
+def test_fetch_rss_feed_blocked_is_deleted(factories, mocker):
+    channel = factories["audio.Channel"](external=True)
+
+    mocker.patch.object(
+        tasks.serializers,
+        "get_channel_from_rss_url",
+        side_effect=tasks.serializers.BlockedFeedException(),
+    )
+    tasks.fetch_rss_feed(channel.rss_url)
+
+    with pytest.raises(channel.DoesNotExist):
+        channel.refresh_from_db()
diff --git a/api/tests/audio/test_views.py b/api/tests/audio/test_views.py
index 4a762c6f1b..6d6e52f45b 100644
--- a/api/tests/audio/test_views.py
+++ b/api/tests/audio/test_views.py
@@ -251,6 +251,19 @@ def test_channel_rss_feed(factories, api_client, preferences):
     assert response["Content-Type"] == "application/rss+xml"
 
 
+def test_channel_rss_feed_redirects_for_external(factories, api_client, preferences):
+    preferences["common__api_authentication_required"] = False
+    channel = factories["audio.Channel"](external=True)
+    factories["music.Upload"](library=channel.library, playable=True)
+
+    url = reverse("api:v1:channels-rss", kwargs={"composite": channel.uuid})
+
+    response = api_client.get(url)
+
+    assert response.status_code == 302
+    assert response["Location"] == channel.rss_url
+
+
 def test_channel_rss_feed_remote(factories, api_client, preferences):
     preferences["common__api_authentication_required"] = False
     channel = factories["audio.Channel"]()
@@ -291,3 +304,65 @@ def test_channel_metadata_choices(factories, api_client):
 
     assert response.status_code == 200
     assert response.data == expected
+
+
+def test_subscribe_to_rss_feed_existing_channel(
+    factories, logged_in_api_client, mocker
+):
+    actor = logged_in_api_client.user.create_actor()
+    rss_url = "http://example.test/rss.url"
+    channel = factories["audio.Channel"](rss_url=rss_url, external=True)
+    url = reverse("api:v1:channels-rss_subscribe")
+
+    response = logged_in_api_client.post(url, {"url": rss_url})
+
+    assert response.status_code == 201
+
+    subscription = actor.emitted_follows.select_related(
+        "target__channel__artist__description",
+        "target__channel__artist__attachment_cover",
+    ).latest("id")
+
+    assert subscription.target == channel.actor
+    assert subscription.approved is True
+    assert subscription.fid == subscription.get_federation_id()
+
+    setattr(subscription.target.channel.artist, "_tracks_count", 0)
+    setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
+
+    expected = serializers.SubscriptionSerializer(subscription).data
+
+    assert response.data == expected
+
+
+def test_subscribe_to_rss_feed_existing_subscription(
+    factories, logged_in_api_client, mocker
+):
+    actor = logged_in_api_client.user.create_actor()
+    rss_url = "http://example.test/rss.url"
+    channel = factories["audio.Channel"](rss_url=rss_url, external=True)
+    factories["federation.Follow"](target=channel.actor, approved=True, actor=actor)
+    url = reverse("api:v1:channels-rss_subscribe")
+
+    response = logged_in_api_client.post(url, {"url": rss_url})
+
+    assert response.status_code == 201
+
+    assert channel.actor.received_follows.count() == 1
+
+
+def test_subscribe_to_rss_creates_channel(factories, logged_in_api_client, mocker):
+    logged_in_api_client.user.create_actor()
+    rss_url = "http://example.test/rss.url"
+    channel = factories["audio.Channel"]()
+    get_channel_from_rss_url = mocker.patch.object(
+        serializers, "get_channel_from_rss_url", return_value=(channel, [])
+    )
+    url = reverse("api:v1:channels-rss_subscribe")
+
+    response = logged_in_api_client.post(url, {"url": rss_url})
+
+    assert response.status_code == 201
+    assert response.data["channel"]["uuid"] == channel.uuid
+
+    get_channel_from_rss_url.assert_called_once_with(rss_url)
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 2d32f42cff..8b1dddd48c 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -87,6 +87,8 @@ def cache():
     """
     yield django_cache
     django_cache.clear()
+    if "service_actor" in actors._CACHE:
+        del actors._CACHE["service_actor"]
 
 
 @pytest.fixture(autouse=True)
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index ccf6a796da..538c43960f 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -1444,7 +1444,7 @@ def test_channel_actor_outbox_serializer(factories):
 
 
 def test_channel_upload_serializer(factories):
-    channel = factories["audio.Channel"]()
+    channel = factories["audio.Channel"](library__privacy_level="everyone")
     content = factories["common.Content"]()
     upload = factories["music.Upload"](
         playable=True,
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 341f052d4a..05604f7efa 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -186,6 +186,18 @@ def test_music_library_retrieve_excludes_channel_libraries(factories, api_client
     assert response.status_code == 404
 
 
+def test_actor_retrieve_excludes_channel_with_private_library(factories, api_client):
+    channel = factories["audio.Channel"](external=True, library__privacy_level="me")
+
+    url = reverse(
+        "federation:actors-detail",
+        kwargs={"preferred_username": channel.actor.preferred_username},
+    )
+    response = api_client.get(url)
+
+    assert response.status_code == 404
+
+
 def test_music_library_retrieve_page_public(factories, api_client):
     library = factories["music.Library"](privacy_level="everyone", actor__local=True)
     upload = factories["music.Upload"](library=library, import_status="finished")
diff --git a/api/tests/music/test_filters.py b/api/tests/music/test_filters.py
index 30f8f58b9c..9bb8fd1580 100644
--- a/api/tests/music/test_filters.py
+++ b/api/tests/music/test_filters.py
@@ -113,7 +113,7 @@ def test_track_filter_tag_multiple(
 
 
 def test_channel_filter_track(factories, queryset_equal_list, mocker, anonymous_user):
-    channel = factories["audio.Channel"]()
+    channel = factories["audio.Channel"](library__privacy_level="everyone")
     upload = factories["music.Upload"](
         library=channel.library, playable=True, track__artist=channel.artist
     )
@@ -129,7 +129,7 @@ def test_channel_filter_track(factories, queryset_equal_list, mocker, anonymous_
 
 
 def test_channel_filter_album(factories, queryset_equal_list, mocker, anonymous_user):
-    channel = factories["audio.Channel"]()
+    channel = factories["audio.Channel"](library__privacy_level="everyone")
     upload = factories["music.Upload"](
         library=channel.library, playable=True, track__artist=channel.artist
     )
diff --git a/front/src/components/audio/SearchBar.vue b/front/src/components/audio/SearchBar.vue
index 21fdb84997..37844d65d8 100644
--- a/front/src/components/audio/SearchBar.vue
+++ b/front/src/components/audio/SearchBar.vue
@@ -57,7 +57,6 @@ export default {
       },
       onSelect (result, response) {
         jQuery(self.$el).search("set value", searchQuery)
-        console.log('SELECTEING', result)
         router.push(result.routerUrl)
         jQuery(self.$el).search("hide results")
         return false
@@ -83,6 +82,10 @@ export default {
               code: 'federation',
               name: self.$pgettext('*/*/*', 'Federation'),
             },
+            {
+              code: 'podcasts',
+              name: self.$pgettext('*/*/*', 'Podcasts'),
+            },
             {
               code: 'artists',
               route: 'library.artists.detail',
@@ -168,6 +171,25 @@ export default {
                 }
               }
             }
+            else if (category.code === 'podcasts') {
+              if (objId) {
+                isEmptyResults = false
+                let searchMessage = self.$pgettext('Search/*/*', 'Subscribe to podcast via RSS')
+                results['podcasts'] = {
+                  name: self.$pgettext('*/*/*', 'Podcasts'),
+                  results: [{
+                    title: searchMessage,
+                    routerUrl: {
+                      name: 'search',
+                      query: {
+                        id: objId,
+                        type: "rss"
+                      }
+                    }
+                  }]
+                }
+              }
+            }
             else {
               initialResponse[category.code].forEach(result => {
                 isEmptyResults = false
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 07bb210e32..628ee7d72f 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -80,7 +80,8 @@ export default new Router({
       component: () =>
         import(/* webpackChunkName: "core" */ "@/views/Search"),
       props: route => ({
-        initialId: route.query.id
+        initialId: route.query.id,
+        type: route.query.type,
       })
     },
     {
diff --git a/front/src/views/Search.vue b/front/src/views/Search.vue
index 68518f0b70..06a355738d 100644
--- a/front/src/views/Search.vue
+++ b/front/src/views/Search.vue
@@ -2,9 +2,12 @@
   <main class="main pusher" v-title="labels.title">
     <section class="ui vertical stripe segment">
       <div class="ui small text container">
-        <form :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="createFetch">
-          <h2><translate translate-context="Content/Fetch/Title">Retrieve a remote object</translate></h2>
-          <p>
+        <form :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="submit">
+          <h2>{{ labels.title }}</h2>
+          <p v-if="type === 'rss'">
+            <translate translate-context="Content/Fetch/Paragraph">Use this form to subscribe to a podcast using its RSS feed.</translate>
+          </p>
+          <p v-else>
             <translate translate-context="Content/Fetch/Paragraph">Use this form to retrieve an object hosted somewhere else in the fediverse.</translate>
           </p>
           <div v-if="errors.length > 0" class="ui negative message">
@@ -23,16 +26,9 @@
             <translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
           </button>
         </form>
-        <div v-if="!isLoading && fetch && fetch.status === 'finished'">
-          <div class="ui hidden divider"></div>
-          <h2><translate translate-context="Content/Fetch/Title/Noun">Result</translate></h2>
-          <div class="ui hidden divider"></div>
-          <div v-if="objComponent" class="ui app-cards cards">
-            <component v-bind="objComponent.props" :is="objComponent.type"></component>
-          </div>
-          <div v-else class="ui warning message">
-            <p><translate translate-context="Content/*/Error message.Title">This kind of object isn't supported yet</translate></p>
-          </div>
+        <div v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" class="ui warning message">
+          <p><translate translate-context="Content/*/Error message.Title">This kind of object isn't supported yet</translate></p>
+        </div>
         </div>
       </div>
     </section>
@@ -43,22 +39,12 @@
 import axios from 'axios'
 
 
-import AlbumCard from '@/components/audio/album/Card'
-import ArtistCard from '@/components/audio/artist/Card'
-import LibraryCard from '@/views/content/remote/Card'
-import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
-
 export default {
   props: {
-    initialId: { type: String, required: false}
-  },
-  components: {
-    ActorLink:  () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"),
-    ArtistCard,
-    AlbumCard,
-    LibraryCard,
-    ChannelEntryCard,
+    initialId: { type: String, required: false},
+    type: { type: String, required: false},
   },
+  components: {},
   data () {
     return {
       id: this.initialId,
@@ -70,14 +56,25 @@ export default {
   },
   created () {
     if (this.id) {
-      this.createFetch()
+      if (this.type === 'rss') {
+        this.rssSubscribe()
+
+      } else {
+        this.createFetch()
+      }
     }
   },
   computed: {
     labels() {
+      let title = this.$pgettext('Head/Fetch/Title', "Search a remote object")
+      let fieldLabel = this.$pgettext('Head/Fetch/Field.Placeholder', "URL or @username")
+      if (this.type === "rss") {
+        title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast RSS feed")
+        fieldLabel = this.$pgettext('*/*/*', "RSS Feed URL")
+      }
       return {
-        title: this.$pgettext('Head/Fetch/Title', "Search a remote object"),
-        fieldLabel: this.$pgettext('Head/Fetch/Field.Placeholder', "URL or @username"),
+        title,
+        fieldLabel
       }
     },
     objInfo () {
@@ -85,43 +82,38 @@ export default {
         return this.fetch.object
       }
     },
-    objComponent () {
-      if (!this.obj) {
+    redirectRoute () {
+      if (!this.objInfo) {
         return
       }
       switch (this.objInfo.type) {
-        case "account":
-          return {
-            type: "actor-link",
-            props: {actor: this.obj}
-          }
-        case "library":
-          return {
-            type: "library-card",
-            props: {library: this.obj}
-          }
-        case "album":
-          return {
-            type: "album-card",
-            props: {album: this.obj}
-          }
-        case "artist":
-          return {
-            type: "artist-card",
-            props: {artist: this.obj}
-          }
-        case "upload":
-          return {
-            type: "channel-entry-card",
-            props: {entry: this.obj.track}
-          }
+        case 'account':
+          let [username, domain] = this.objInfo.full_username.split('@')
+          return {name: 'profile.full', params: {username, domain}}
+        case 'library':
+          return {name: 'library.detail', params: {id: this.objInfo.uuid}}
+        case 'artist':
+          return {name: 'library.artists.detail', params: {id: this.objInfo.id}}
+        case 'album':
+          return {name: 'library.albums.detail', params: {id: this.objInfo.id}}
+        case 'track':
+          return {name: 'library.tracks.detail', params: {id: this.objInfo.id}}
+        case 'upload':
+          return {name: 'library.uploads.detail', params: {id: this.objInfo.uuid}}
 
         default:
-          return
+          break;
       }
     }
   },
   methods: {
+    submit () {
+      if (this.type === 'rss') {
+        return this.rssSubscribe()
+      } else {
+        return this.createFetch()
+      }
+    },
     createFetch () {
       if (!this.id) {
         return
@@ -148,58 +140,38 @@ export default {
         self.errors = error.backendErrors
       })
     },
-    getObj (objInfo) {
+    rssSubscribe () {
       if (!this.id) {
         return
       }
+      this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
+      this.fetch = null
       let self = this
+      self.errors = []
       self.isLoading = true
-      let url = null
-      switch (objInfo.type) {
-        case 'account':
-          url = `federation/actors/${objInfo.full_username}/`
-          break;
-        case 'library':
-          url = `libraries/${objInfo.uuid}/`
-          break;
-        case 'artist':
-          url = `artists/${objInfo.id}/`
-          break;
-        case 'album':
-          url = `albums/${objInfo.id}/`
-          break;
-        case 'upload':
-          url = `uploads/${objInfo.uuid}/`
-          break;
-
-        default:
-          break;
-      }
-      if (!url) {
-        this.errors.push(
-          self.$pgettext("Content/*/Error message.Title", "This kind of object isn't supported yet")
-        )
-        this.isLoading = false
-        return
+      let payload = {
+        url: this.id
       }
-      axios.get(url).then((response) => {
-        self.obj = response.data
+
+      axios.post('channels/rss-subscribe/', payload).then((response) => {
         self.isLoading = false
+        self.$store.commit('channels/subscriptions', {uuid: response.data.channel.uuid, value: true})
+        self.$router.push({name: 'channels.detail', params: {id: response.data.channel.uuid}})
+
       }, error => {
         self.isLoading = false
         self.errors = error.backendErrors
       })
-    }
+    },
   },
   watch: {
     initialId (v) {
       this.id = v
       this.createFetch()
     },
-    objInfo (v) {
-      this.obj = null
+    redirectRoute (v) {
       if (v) {
-        this.getObj(v)
+        this.$router.push(v)
       }
     }
   }
diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue
index a20efddc57..ca086d5f95 100644
--- a/front/src/views/channels/DetailBase.vue
+++ b/front/src/views/channels/DetailBase.vue
@@ -14,7 +14,7 @@
               </div>
               <div class="ui column right aligned">
                 <tags-list v-if="object.artist.tags && object.artist.tags.length > 0" :tags="object.artist.tags"></tags-list>
-                <actor-link :avatar="false" :actor="object.attributed_to" :display-name="true"></actor-link>
+                <actor-link v-if="object.actor" :avatar="false" :actor="object.attributed_to" :display-name="true"></actor-link>
                 <template v-if="totalTracks > 0">
                   <div class="ui hidden very small divider"></div>
                   <translate translate-context="Content/Channel/Paragraph"
@@ -125,7 +125,7 @@
               <div class="left aligned" :title="object.artist.name">
                 {{ object.artist.name }}
                 <div class="ui hidden very small divider"></div>
-                <div class="sub header ellipsis" :title="object.actor.full_username">
+                <div class="sub header ellipsis" v-if="object.actor ":title="object.actor.full_username">
                   {{ object.actor.full_username }}
                 </div>
               </div>
@@ -268,7 +268,7 @@ export default {
       this.isLoading = true
       let channelPromise = axios.get(`channels/${this.id}`).then(response => {
         self.object = response.data
-        if (self.id == response.data.uuid && response.data.actor) {
+        if ((self.id == response.data.uuid) && response.data.actor) {
           // replace with the pretty channel url if possible
           let actor = response.data.actor
           if (actor.is_local) {
-- 
GitLab