This is a library for making podcast software in Clojure or ClojureScript.
app.* namespaces that compose the low-level functions.
These provide convenience and structure so you can start making a podcast app fast.AI;DR this introduction was written by a regular human, but I want to be transparent here: large swaths of this library were developed with assistance from frontier LLMs. I have spent a long time on the API design and on hardening the library in terms of both security and performance, but it simply wouldn't be feasible for me to develop a library with such broad support for modern podcasting features without LLMs helping to implement a lot of it.
podcast namespace of
Podcasting 2.0 all turn into the same normalised Clojure data,
as do Media RSS, Google Play and the other
vocabularies of podcast feeds.Podcasts are one of few spaces left online where decentralisation is still the norm.
A podcast is published as a feed: an RSS file that the show's host, i.e. the
service that stores its audio, serves at a URL. The feed describes the show in
its <channel>, and each episode in an <item>, which links to the audio file
in an <enclosure> and holds the show notes. Most feeds also use tags that RSS
doesn't define, mainly Apple's itunes: tags and the podcast: tags of
Podcasting 2.0 (see an introduction
on Podnews). Listeners find shows in directories, e.g. Apple Podcasts or the
Podcast Index, and subscribe to them in an app, which checks each feed now and
then for new episodes.
The names in podcast-clj follow these terms. A parsed feed is a map of the
channel and its items as the host wrote them, while app.show and
app.episode give a show and an episode as a listener should see them, e.g. with one
title and one image, whichever tags they came from. The other namespaces
are mostly named after the part of podcasting they cover, e.g. directory,
comments, value for payments and analytics for download counts.
The things a typical listener will do (subscribing, queueing, playing, etc.)
go into their profile, a CRDT of mind-meld,
which like-minded syncs between their devices.
Each device has its own library, a piece of state which includes:
the aforementioned profile, the podcast feeds that the device has fetched,
and a local search index for fast search results.
Add the latest release from Clojars
to the :deps in your deps.edn
(requires Clojure 1.11+ & Java 11+):
dk.simongray/podcast-clj {:mvn/version "0.1.0"}
For changes that aren't released yet, use a Git dependency
with the SHA of the latest commit on master instead.
For ClojureScript, also set :deps true in your shadow-cljs.edn,
since shadow-cljs only reads Git dependencies from deps.edn.
Then try parsing a feed as a Clojure map:
(require '[dk.simongray.podcast.feed.rss :as rss])
(def feed (rss/parse (slurp "https://feeds.podcastindex.org/pc20.xml")))
(:rss/title feed)
;; => "Podcasting 2.0"
(-> feed :rss/items first :podcast/transcripts)
;; => [{:url "https://…" :type "application/x-subrip" :rel "captions"}]
Each key has the namespace of its vocabulary, and a report in the metadata says
what podcast-clj repaired or normalised while parsing. For example, this feed
writes itunes:explicit as no instead of false, gives its SRT transcripts
the type application/srt, and uses the deprecated podcast:images:
(require '[dk.simongray.podcast.diagnostics :as diagnostics])
(map :id (diagnostics/summarize (rss/report feed)))
;; => (:quirk/explicit-value :quirk/transcript-type :quirk/deprecated-images)
For one answer across the vocabularies, use feed.resolve:
(require '[dk.simongray.podcast.feed.resolve :as resolve])
(select-keys (resolve/properties feed) [:title :author :categories])
;; => {:title "Podcasting 2.0" :author "Podcast Index LLC" :categories [["News & Politics"] ["Technology"]]}
NOTE: on both platforms, podcast-clj repairs the most common XML errors of
feeds, e.g. bare ampersands. With org.jsoup/jsoup on the classpath, it also
reads feeds on the JVM that are still not well-formed XML. In Node, you also
need a DOMParser to read XML, e.g. that of @xmldom/xmldom as
globalThis.DOMParser.
You can create a full-featured podcast app with the app.* namespaces, and
sync the listener's devices through the stores of
like-minded, e.g. a folder,
WebDAV or the gpodder API. Subscribing, playing, refreshing, searching,
saving, and syncing are each one function call.
A call that changes the library returns a new version, and playing, refreshing
and syncing also record what they did in it:
(require '[dk.simongray.like-minded.store.folder :as folder]
'[dk.simongray.podcast :as podcast]
'[dk.simongray.podcast.app.library :as library])
;; make a library for this device, and subscribe to a show
(def url "https://feeds.podcastindex.org/pc20.xml")
(def lib (atom (-> (library/make "laptop" {:name "Laptop"})
(library/subscribe url (podcast/fetch! url)))))
;; the id of the newest episode
(def id (let [[show item] (first (library/episodes @lib {:order :newest}))]
(library/episode-id @lib show item)))
;; what an episode page shows, e.g. the show notes, chapters and transcript
(merge (library/episode @lib id) (library/read-files! @lib id))
;; search the episodes and shows; a word with a typo still finds them
(map (comp :title :stored) (library/search @lib "podcastng"))
;; => ("Podcasting 2.0" "Episode 73: Spinning Gold out of Yarn" …)
;; tell the library that the player is 754 seconds into the 3600-second
;; episode, and get back what to do with the media element: nothing, here
(:effects (swap! lib library/step [:progress id 754 3600]))
;; => []
;; fetch the feeds that are due for a check, and list the new episodes;
;; the result is the library from before the fetch plus what it fetched, so
;; merge it in, or anything done during the fetch, e.g. playing, is undone
(-> (swap! lib library/merge (library/refresh! @lib)) :refresh :added)
;; => [] right after subscribing, since nothing is due yet.
;; Later, the new episodes: [["917393e3-…" "PC20-274"]]
;; sync the profile with the listener's other devices through a Dropbox folder,
;; and see what failed and what the step reported
(def dropbox (library/backend (folder/store "/Users/simon/Dropbox/Podcasts")))
(-> (swap! lib library/merge (library/sync! @lib [dropbox])) :sync :report)
;; => []
;; keep the library until the next start, and read it back with restore!
(library/save! @lib (folder/store "data"))
;; => {:put ["feed-917393e3-….edn" "segment-37b1….ciff" "index.edn" "library.edn"]
;; :delete []}
;; the listener's profile: the subscriptions, queue, progress, stars and more
(:profile @lib)
NOTE: the profile is a CRDT, so change it with the functions of profile,
not with assoc. Syncing explains how the devices merge.
A podcast app in a browser also needs a server: a proxy for the files that
other sites don't let a page read, and a folder to sync through. The
podcast server in server/ is that server for one
listener. It serves the Ring handlers of the proxy of
wary-fetch, the WebDAV server of
like-minded, built on
webdav-clj, and gpodder.server,
which you can also mount in a server of your own.
Learn more:
Look up:
Understand:
Background:
To run the tests, build the jar, or start the inspector, an example app that uses only the public API, see Development.
The podcast-clj project is licensed under the MIT licence.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |