(((•)))
tech.interfluidity.com
a notebook by steve randy waldman
→ See also top, archive, subscribe, drafts blog, interfluidity main
2023-09-17 ⇒

# Taking control of podcasts via RSS

TL; DR: I did it, and like it!

But it was not so straightforward that anyone can just ditch their podcast app for an RSS reader and have a good experience.

I’m an avid podcast listener, so the podcast subscription list I curate is important to me. My phone is an iPhone and my laptop a Mac, so initially I just used Apple’s Podcasts app. Podcasts were conveniently synced between my phone and computer. That was nice.

But I don’t have Apple CarPlay, and didn’t like the UI for finding podcasts when I was driving. I was doing more clicking around and searching on the phone than seemed prudent. So, I looked at other podcast apps, and was pretty happy with Castro for a while.

Apple — to its vendor-lock-in shame — no longer supports exports of podcasts to OPML from the Podcasts app. It used to, when Podcasts were part of iTunes. There's a workaround here that I haven’t tried. So migrating to Castro meant a lot of manual resubscribing.

Castro is a fine app. It is not evil — it supports OPML import and export. But it’s iPhone only, and I want access to my feeds on my computer too. And I just want to feel like I own my own darned subscription list, and podcasts are published as RSS feeds, so why can't I just subscribe to them with my RSS reader?

So I did!

I use Inoreader and I am, overall, a big fan. I feel a bit of cognitive dissonance over that — in general I am trying to disentangle myself from centralized platforms as an architecture, and Inoreader is a centralized platform. I could use a local app like NetNewsWire (my first RSS reader!), which now will sync between phone and computer.

But I’m subscribed to more than 1000 feeds, I wonder if that won’t be a lot, especially for my phone. I like Inoreader's “monitored keywords" feeds. In general I’ve been impressed by Inoreader. It feels like a power tool. They are not a dominant platform in their space, so they're less likely to enshittify than a monopolist. For now, I am happy to be a paying customer of theirs.

Migrating by OPML worked easily, both the Castro export and Inoreader import. I end up with a podcasts folder in Inoreader. I can browse that folder like any other collection of feeds, and play podcast audio files from each post. Great!

But there were two hitches:

Not a great sort of sort!

1. Inoreader sorts new articles in reverse chronological order using the time your feed receives the article, rather than the time of the article's publication. So when you subscribe to a feed, a folder that contains it along with other feeds will show all of the new feed's posts at the top.

   I suspect this is performance motivated — Inoreader builds and caches your feeds in advance, and RSS article publication dates are neither reliable nor stable. If it tried to cache publication-sorted feeds, it would end up frequently, expensively reconstructing them.

   Nevertheless, the effect of this has always been a bit annoying. Nearly all my feeds are organized into folders. When I subscribe to a new blog, the top of that folder gets monopolized.

   For blogs, this is not a big deal. Blogs typically keep just a few posts in their RSS, maybe the last five or ten articles. And when reading, scrolling down is not a problem.

   Podcast "blogs" sometimes have tens of articles in their RSS. You have to scroll a lot farther down. And I often want to listen to podcasts while driving. I can’t afford a lot of messing around to get past an archive of my last subscription.

2. While you can play episodes in Inoreader’s mobile app, it’s not a great podcast app. Playback takes over the screen of your phone, and stops if switch to anywhere else. It won’t remember where you were when you go back.

   I think there’s an opportunity for Inoreader to become a better audio playback app and become the podcast app for RSS lovers. But it isn’t there yet.

So.

I wanted to re-sort my feed by publication time rather than feed-saw-it time, and I wanted podcasts to end up in a richer audio app.

Inoreader power tools to the rescue! Inoreader lets you publish the folders you curate as new own RSS feeds. What if I subscribed to this one feed of feeds from a proper podcasts app?!

That works! You get a good listening experience, can listen even when you switch out of the app, can resume episodes where you left off.

But…

1. It does not typically cause the feed to get sorted by publication date. Podcast apps use the ordering in the feed itself; and

2. The feed contains a jumbled mix of many podcasts' episode titles, with no information about which podcast each episode is from.

It’s here that I start to get a bit obsessive.

I’ve done a fair amount of work serving and transforming RSS and generating podcast feeds. What if I let the RSS feed server that I’ve already built and deployed subscribe to my podcast feed, sort the episodes by publication time, and then re-serve them?

Since I have the RSS, I can just inject the podcast names into the episode titles, so my items in my feed look like “Left Anchor: Finland's Cooperative Culture”, where Left Anchor is the podcast name, and the rest is the episode title.

From `SubscribedPodcasts.scala`:

      private val PrefixTransformations = Map("Podcasts" -> "TAP")

      private def prependFeedTitleToItemTitles(rssElem: Elem): Elem =
        val feedPrefix =
          val queryResult = (rssElem \ "channel").map(_ \ "title")
          if queryResult.nonEmpty then
            val rawPrefix = queryResult.head.text.trim
            val goodPrefix = PrefixTransformations.getOrElse(rawPrefix, rawPrefix)
            (goodPrefix + ": ")
          else
            ""
        val rule = new RewriteRule:
          override def transform(n: Node): Seq[Node] = n match
            case elem: Elem if elem.label == "item" => prefixTitlesOfItemElem(feedPrefix, elem)
            case other => other
        val transform = new RuleTransformer(rule)
        transform(rssElem).asInstanceOf[Elem]

Since my RSS server is in the business of unifying feeds, I used another Inoreader power tool — serving OPML so you can subscribe to subscription lists! I had my app subscribe to my list of feeds, periodically refresh that from Inoreader, then load and and merge all the feeds itself. That way I can control how feeds are merged. (For example, I am very careful about preserving XML namespaces in merged feeds.)

From `InterfluidityMain.scala`

      val subscribedPodcastsMetaSources = immutable.Seq(
        MetaSource.OPML(URL("https://www.inoreader.com/reader/subscriptions/export/user/1005956602/label/Podcasts"), eachFeedTransformer = SubscribedPodcasts.bestAttemptEmbellish),
        MetaSource.OPML(URL("https://www.inoreader.com/reader/subscriptions/export/user/1005956602/label/Podcasts+HF"), eachFeedTransformer = SubscribedPodcasts.bestAttemptEmbellish),
      )

Now any podcast app that lets you subscribe via a simple RSS url (most, but not all of them!) can subscribe to my feed.

I was done!

But I was vain.

I didn’t like the look of my feed. There were no pretty cover graphics, just the text name of each feed. And my gigafeed itself had no cover image. So…

If I was transforming XML to modify titles, I might as well transform it to add images. Unless an episode has an episode-specific image defined (usually they don’t), I take the cover image of the feed and make it the cover image of the episode. Now, when you look at my all-my-podcasts feed in a podcast app that supports episode images, you see the cover image for the podcast next to each episode.

From `SubscribedPodcasts.scala`:

      private def copyItunesImageElementsToItems(rssElem: Elem): Elem =
        val mbItunesFeedImage =
          val queryResult = (rssElem \ "channel").flatMap(_ \ "image").filter(_.asInstanceOf[Elem].prefix == "itunes")
          if queryResult.nonEmpty then Some(queryResult.head) else None
        val mbRegularFeedImage =
          val queryResult = (rssElem \ "channel").flatMap(_ \ "image").filter(_.asInstanceOf[Elem].prefix == null)
          if queryResult.nonEmpty then Some(queryResult.head) else None
        val mbFeedImage = mbItunesFeedImage orElse mbRegularFeedImage.map: regularImageElem =>
          val url = (regularImageElem \ "url").head.text.trim
          Element.Itunes.Image(href = url).toElem
        mbFeedImage.fold(rssElem): feedImage =>
          val rule = new RewriteRule:
            override def transform(n: Node): Seq[Node] = n match
              case elem: Elem if elem.label == "item" =>
                if (elem \ "image").isEmpty then
                  elem.copy(child = elem.child :+ feedImage.asInstanceOf[Elem])
                else
                  elem
              case other => other
          val transform = new RuleTransformer(rule)
          transform(rssElem).asInstanceOf[Elem]

Messing around in midjourney, I "prompted” a cover image for my overall feed of feeds, and transformed the almost-final merged feed to include that image.

Now everything is very pretty. You can see what it looks like in Podcast Republic. (This is also the image at the top of the post.)

I noticed that some apps were undesirably segregating episodes based on alleged “seasons”, putting episode from the “latest” season near the top. Obviously, there can be no consistency of seasons, since I am taking episodes from a kaleidoscope of different shows. So, I add yet another transformation to feeds before merging them, one which strips any `<itunes:season>` elements.

The `RewriteRule` API of Scala’s standard XML library performs abysmally. I transform each feed three times (modify the title, add an episode image, strip seasons), and then I transform the final feed once (to insert my cover image).

I think I could, and should, combine the transformations into a single pass that performs all three per-feed, pre-merge transformations. But it's conceptually easier to just run three passes. Even though processing a single feed can take up to 10 seconds, my ZIO-based app trivially parallelizes the transformations. Plus, reloads/reconstructions of the megafeed happen only once every 30 minutes.

So, although I feel a bit of professional embarrassment over the very remediable poor performance of feed reconstruction, it has no practical cost, and I haven’t (yet) bothered to fix it. Updated feeds replace prior feeds atomically, so there's no downtime while a new feed is under construction.

Anyway, it was all a bit much, a bit more than I had bargained for when, almost on a whim, I set out to RSS-ify my podcast management.

But now it’s done. I manage and subscribe to podcasts in Inoreader. A bit omphaloskeptically, I resubscribe from Inoreader to the re-sort of those feeds performed by my server. I listen straight off of Inoreader on my laptop. On the phone, I bounce between several apps — mostly Podcast Republic and Podcast Guru — to listen to whatever I’m into. (I still like Castro, but I've left my old setup alone there, just in case.) Each app sees the same feed, synced to Inoreder. Information the apps themselves generate, like for how long an episode has been listened to or whether it’s already completed, does not get synced between apps. I don’t find that to be a problem.

Inoreader supports tags, and will export an RSS feed of posts with a given tag. I’ve created a tag called “Queued”, and I have my podcast apps subscribe to that too. So I can browse on my desktop, tag episodes I may be particularly interested in, and find those quickly in a second feed each podcast app subscribes to. In general, I subscribe to two feeds in each app, my gigafeed that merges all of my podcast subscriptions, and my Queued feed which offers just a few episodes that I’ve selected.

So far it's working pretty well!

A couple of quick miscellaneous tips:

- podnews.net is a great resource for finding podcast RSS feeds. Just search by name, if necessary restricting to "The Podcast Index". When you find a podcast, you'll see a gazillion icons for apps and platforms, but the very last one will be the podcast's clean, beautiful, old-fashioned RSS feed.

- I subscribe to some very high frequency podcasts, like "NPR News Now", which comes out each hour. A wonderful feature of Inoreader is it caches the full history of your feeds. But here this becomes a problem. To prevent the history of high frequency feeds from drowning out eveything in my Inoreader podcasts folder I segregate high-frequency feeds into a "Podcasts HF" folder. When I merge feeds, I draw on the OPML from this folder as well as from my main podcasts folder. NPR's actual feed always includes only the single most recent episode, so it doesn't overwhelm my merged feed, which loads the feed to merge from NPR, not Inoreader folder.

You can subscribe to my subscriptions if you want, the URL is `https://www.interfluidity.com/unify-rss/subscribed-podcasts.rss`.

← Suppressing bloop for scala-cli managed services
↑↑↑
Referring to Scala collections →