This guide shows how to build each part of a podcast app, from finding a
show to syncing devices. Each section starts with the one call that's
usually enough, and then shows the lower-level functions for when you need
more control. Quickstart runs the first steps at a REPL,
and the inspector in web/ is a working example.
The subscriptions, feeds, queue and play progress of a podcast app are all kept in one map, the library, which fits in an atom:
(def lib
(atom (library/make device-id {:name "Phone"
:config {:languages ["da" "en"]}})))
(swap! lib library/subscribe url (podcast/fetch! url))
(swap! lib library/merge (library/refresh! @lib))
(swap! lib library/merge (library/sync! @lib backends))
Change it with swap!, and put what refresh! and sync! return into
it with library/merge, which keeps what the listener did while they
fetched. In ClojureScript they return a promise:
(.then (library/refresh! @lib) #(swap! lib library/merge %)). Make the
device-id once for each installation, e.g. with (str (random-uuid)),
and keep it.
Each function returns the library along with what it did: :effects
after step, :refresh after refresh!, :sync after sync! and
:restore after restore!. The :config given to make holds the
options that the other library functions use, and the docstring of
app.library lists them. Keep the library between runs with
library/save! and library/restore!, as Keep the library between
runs shows.
The parts of the library work with the namespaces they come from, e.g.
every function of profile, and of its parts such as profile.queue,
takes (:profile @lib). The profile is a CRDT, so that devices can merge
their changes in any order, and where two changed the same thing, the
later change wins. Change it only with these functions, e.g.
(swap! lib library/update-profile profile.queue/enqueue id), and never
with assoc or dissoc.
Each change is stamped with its time, which the functions of
app.library read from the clock, the setting :now-fn, so a test can fix
it. For an event that you handle later, e.g. in a background task, give
the time it happened as the option :at. Since update-profile takes no
options, call the function of profile with that time instead, e.g.
(swap! lib update :profile profile.queue/enqueue id at).
The examples call each namespace by the last part of its name, e.g.
library for dk.simongray.podcast.app.library, or by a longer name
where that part would be unclear, e.g. feed.guid, profile.queue or
nostr-comments. The last section
requires them all. The examples run on the JVM, except those for a
browser, and in ClojureScript the functions that send requests return
promises.
Some of the namespaces come from libraries that were spun out of podcast-clj. It depends on them, so they come with it:
http, url, proxy and
secretstore, folder, webdav, webdav-server, encrypted and
indexeddbcrdtsearch, search.queries and ciffhtmlwebsublibrary/step say.library/plan says, when the
network and the battery allow it, and tell the library when you do.podping/poll!, and Namespaces says which.DOMParser, e.g. that of @xmldom/xmldom as
globalThis.DOMParser. The folder store needs Node 20.16 or 22.3 and
later, and Nostr comments and wallets need the global WebSocket of
Node 22. A web worker has no DOMParser either, so install one there
too.:send of wary-fetch's
electron/sender, which sends through the main process, or of
capacitor/sender, which sends through the native HTTP of Capacitor.store.kv and
store.folder the functions of your key-value store and file system,
e.g. MMKV and expo-file-system, since there's no IndexedDB or folder
of its own.In ClojureScript, add podcast-clj to deps.edn and let shadow-cljs read
it with :deps true. A browser needs no npm packages, and Node and web
workers only the DOMParser above.
A few feeds aren't well-formed XML even after the repairs that
podcast-clj makes, e.g. with a tag that's never closed. On the JVM, jsoup
reads them when it's on the classpath. In JavaScript, they're refused as
malformed, unless you install a lenient parser of your own with
repair/use-lenient-parser!, e.g. over htmlparser2. It takes a function
of the XML text that gives the raw Hiccup that qname-hiccup/parse-raw
gives.
Few feed hosts and directories let a page of another site read their
answers, so a page sends its requests through your own server. Mount the
proxy of wary-fetch there, and
give its sender as :send:
;; on the server, as a route of Pedestal or any other Ring router
["/proxy" :any (proxy/handler) :route-name ::proxy]
;; in the page, in the config of the library
(library/make device-id {:config {:send (proxy/sender "/proxy")}})
Every function that sends requests then works in the page as it does on the JVM, except the sync over WebDAV and the gpodder API. The proxy fetches only URLs on the public internet, and refuses to be opened as a page, but it fetches for anyone who can reach it, so put it behind your login.
The proxy only sends GET and HEAD, so in a page, the sync over WebDAV or
the gpodder API reaches only a server on your own origin, e.g. that of
store.webdav.server, or one that lets your page through CORS. A WebDAV
server must expose the ETag too. Syncing through a folder needs no
server, as Sync through a folder in a browser
shows.
A page reaches Nostr relays by itself, so for comments, wrap the :send
that comments/fetch! has by default: (proxy/sender "/proxy" {:send comments/send!}). The page's cookies and credentials don't go on through
the proxy, so to fetch a private feed through it, give the handler
{:forward (conj (:forward proxy/defaults) "authorization")}, unless your
own login uses that header.
The Podcast Index and OP3 take their credentials in headers too. To keep
the secrets on your server, e.g. for a public site, sign the requests
there, and give the page credentials without the :secret, or a nil
token for OP3:
;; on the server
(proxy/handler {:send (podcastindex/sender credentials
{:send (op3/sender token)})})
;; in the page
(library/make device-id {:config {:send (proxy/sender "/proxy") :index {}}})
Anyone who passes your login then spends the quota of your accounts. If
each listener brings their own key instead, the page signs, and the
handler forwards "x-auth-key", "x-auth-date" and "authorization" in its
:forward, which works only when your login doesn't use that header.
Search with directory/search!. It asks Apple Podcasts, fyyd and
gpodder.net at the same time, and the Podcast Index too when you give it
credentials, and combines the results into one entry per show:
(def found (:results (directory/search! "podcasting 2.0" {:limit 5})))
(map (juxt resolve/title resolve/author :directory/sources) found)
;; => (["Podcasting 2.0" "Podcast Index LLC" {:apple "1584274529" :fyyd 61003}]
;; …)
(def url (resolve/self-url (first found)))
For the charts, use directory/fetch-chart!, optionally for one category
of Apple Podcasts. A source without a chart for the category, the country
or the language ends up in the :report:
(def chart
(directory/fetch-chart! {:category "Technology" :country "dk" :limit 10}))
(map resolve/title (:results chart))
(map (juxt :source :reason) (:report chart))
;; => ([:fyyd "fyyd has no chart by country"]
;; [:gpodder "gpodder.net has no chart by country"])
The Podcast Index has no names for subcategories like Tech News, so give a
category for each source: {:category {:apple "Tech News" :podcastindex "Technology,News"}}.
To show the categories in the listener's language, fetch Apple's names
for them once and keep them, since they rarely change. They're keyed by
the English names that the charts and resolve/categories use. Show the
English name when there's no localised one:
(def names
(:names (directory/fetch-category-names! "dk" "da")))
(map #(get names % %) ["News" "Tech News"])
;; => ("Nyheder" "Teknologinyheder")
For episodes, ask one source with directory/call!. The :episodes of a
show come from Apple, fyyd and the Podcast Index, the :recent episodes
of any show from fyyd and the Podcast Index, and the episodes :by-person
and those :live now from the Podcast Index alone:
(def episodes
(directory/call! :apple :episodes {:id "1584274529" :limit 5}))
(let [e (first episodes)]
(links/link :apple :episode (links/ids (:directory/show e) e)))
;; => "https://podcasts.apple.com/podcast/id1584274529?i=1000794523391"
Each episode has the keys of an item and the show it's from as
:directory/show, so links/ids gives the ids of both.
In a browser, give search! the :send of the proxy.
(swap! lib library/subscribe url (podcast/fetch! url {:max-pages 20})
{:auto-queue? true})
With :max-pages, fetch! also reads the older pages of a paged feed,
which is only needed on the first fetch, since the library keeps the
episodes that later fetches no longer bring back. The setting
:max-pages does the same for a subscription that refresh! fetches for
the first time, e.g. one from another device. The last argument holds the
settings of the subscription, as profile/configure takes them, e.g.
:auto-queue? puts the show's new episodes in the listener's queue. The
listener's preference :subscription-defaults gives the settings of
every new subscription, e.g. {:auto-queue? true}. The subscription
keeps the show's title and its artwork as :image, so a device that
gets it by sync can show it before it fetches the feed.
A failing network or server doesn't make fetch! throw. When it gets no
answer or no feed, the result has an :error diagnostic. A status such as
404 is an answer, so it comes back as :status without an error. Check
the result before you subscribe, since library/subscribe throws the
diagnostic of a result without a feed:
(def result (podcast/fetch! url))
(:error result) ; => nil, or e.g. {:id :fetch/unreachable :reason "…" …}
To subscribe without a fetch, e.g. while offline, leave the result out:
(swap! lib library/subscribe url). Then refresh! fetches the feed,
and a feed that would take over another subscription goes in its report
instead of throwing.
By default, fetch! refuses a URL on your own network, so that a URL that
someone types into a server can't reach it. For a feed on a home server,
give it {:allow-pred (constantly true)}.
If the listener gives a show's website, a domain or a link from another app
instead of a feed, find the feeds with discovery/find-feeds!:
(def candidates (discovery/find-feeds! "freakshow.fm"))
(map (juxt :title :url :found-by) (:feeds candidates))
;; => (["Freak Show" "https://freakshow.fm/feed/mp3" :link]
;; ["Freak Show" "https://freakshow.fm/feed/opus" :link]
;; ["Freak Show" "https://freakshow.fm/feed" :link] …)
The podcasts come first, so you can subscribe to a single one at once and
let the listener choose among several. Each comes with its parsed :feed,
which library/subscribe takes in place of the result of fetch!. Give
:known (library/urls @lib) to put the shows that the listener has first.
A page can name several feeds of megabytes each, as Freak Show's does, and
:heads? true fetches only the start of each, most often 64 KB. For one
feed, discovery/fetch-head! does the same. The start of a feed is
enough for a preview, and library/subscribe takes it for the title, the
artwork and the guid, while refresh! fetches the whole feed.
When the page names no podcast feed, :search [:apple] also asks a
directory for the shows whose website is on that domain. It's off by
default, since it sends the name of the page to the directory.
A private feed can need a user name and password, which fetch! takes as
:credentials. It doesn't send them on when a redirect leads to another
domain. Keep the password in the platform's keychain, not in the stored
URL, since the subscriptions sync to other devices and go into OPML. To
split a URL like https://user:pass@host/feed into the URL and the
credentials, use url/split-credentials. Wrap the password with
secret/hide, so it prints as #<secret>:
(def creds {:username "simon" :password (secret/hide (from-keychain))})
(podcast/fetch! url {:credentials creds})
In a browser, give fetch! the :send of the proxy.
Without app.library, feeds/add keeps the feed along with its ETag and
Last-Modified, and profile/subscribe adds the subscription. Every id in
the profile uses the key of a feed: the guid that feed.guid/feed-guid
gives it at its first fetch, which stays when the feed later gives itself
another, e.g. after a move. When feeds/add keeps a feed, it puts the
key on it with feed.guid/with-key, so that feed.guid/episode-id gives
ids by it. A feed can declare the guid of another subscription and take
it over, so check a new feed with profile/takeover first.
A folder of audio files, e.g. an audiobook or a set of recordings, can be
a show of its own. Read each file with media/read! through the file
fetcher of util.bytes.ranges, build the show with local/feed, and
subscribe to it:
(defn local-show
"The show of the audio files `names` in the folder `dir`."
[dir names]
(local/feed (for [name names
:let [path (str dir "/" name)]]
{:url (str "file://" path)
:name name
:media (media/read! path {:fetch (ranges/file-fetcher path)})})))
(swap! lib library/subscribe-local
(local-show "/Users/ann/Books/Dune" ["01.mp3" "02.mp3"]))
The episodes go in the order of their file names, and their titles,
dates and durations come from the tags of the files. The show is titled
by the album that the files name, so give local/feed a :title for
files without one. No refresh fetches the show, so subscribe again when
the files change. In a browser, read a File that the listener picked
with ranges/blob-fetcher, and give it an object URL, which lasts only
as long as the page, so build the show again when the page loads.
The same files give the same guids, so a device with the same files
shares the listener's progress. The other devices get the subscription
by sync without a URL, and list it by its title. In OPML, the show is an
outline with isComment="true", which other apps skip, and
profile.opml/from-opml reads it back as such a subscription. To read
the chapters of a local episode, give episode/read-files! the same
:fetch.
(swap! lib library/merge (library/refresh! @lib))
(:refresh @lib)
;; => {:at #inst "…" :added [[feed-guid item-guid] …] :backfilled []
;; :renamed [] :moved {} :report []
;; :counts {:checked 12 :unchanged 10 :changed 2 :failed 0 :left 0}
;; :seconds 1.4}
(library/next-refresh @lib) ; when to refresh again
This fetches whatever is due: the lists that the listener follows, the
feeds of new subscriptions, and the feeds whose next check has come. The
new episodes go to the queue of a show with :auto-queue?, at its end,
or right after the current episode with :next, or to the inbox with
:inbox?. With :auto-queue-max, only the newest of one fetch join the
queue, and the others the inbox when :inbox? is set too. With
:join-where, criteria of criteria/where, only the new episodes that
meet them join either, e.g. {:type #{"full" "bonus"}} leaves the
trailers out. An episode that the listener already played or removed on
another device doesn't come back. An episode older than the newest one
the library knew isn't new either: it's :backfilled rather than
:added, so that a host that publishes its back catalogue again doesn't
fill the queue. The setting :only-newer? false counts those as new too.
The :counts and :seconds say how many feeds were checked and how it
went, e.g. for a status line or a log. Schedule the next refresh at
library/next-refresh: when the first feed or list is due, or right away
after a new subscription. After a refresh that found the device offline,
it waits five minutes, the config's :offline-seconds.
A platform gives a task in the background little time, e.g. half a
minute. Give refresh! that budget as :max-seconds, or a maximum
number of feeds as :max-feeds, and the feeds that it doesn't reach stay
due for the next refresh, as the :left of the counts says. To show
progress, give it :on-feed-fn, which is called as each fetch ends.
To let the listener cancel it, give it a :signal: an AbortSignal in
ClojureScript, or an atom that you set to true on the JVM. The feeds that
it stops or keeps from starting stay due, with no failure counted.
In a web page, parsing the feeds that changed can hold up the page for a
moment, so run the refresh in a web worker, with a store that both
share, e.g. like-minded's store.indexeddb:
:refresh, which the store doesn't keep.:refresh back, since its report tells next-refresh which feeds
failed.Hold a Web Lock around each save and restore, with
navigator.locks.request, so that one of them writes at a time. The
inspector's player does this in web/inspector/worker.cljs, where the
worker is a shadow-cljs module with :web-worker true.
To announce new episodes, turn on the preference :notify?, or the
setting of that name for a show, and ask library/notifications after
each refresh. It returns the new episodes that the listener hasn't
played, by show:
(swap! lib library/update-profile profile/prefer {:notify? true})
(library/notifications @lib)
;; => [{:feed-guid "917393e3-…" :title "Podcasting 2.0"
;; :artwork "https://noagendaassets.com/enc/…_pcifeedimage.png"
;; :episodes [{:id ["917393e3-…" "PC20-274"]
;; :title "Episode 274: \"Vibe Cloning\""
;; :published #inst "2026-10-09T19:37:54Z"}]}]
When a show moves to another host, the new host often gives every episode
a new guid. The library recognises such an episode by its enclosure URL,
or else by its title and a date a day apart at most, and moves its place
in the queue, its marks and its progress to the new id, as :renamed
says. A feed that moved for good moves its subscription too, once a feed
of the show answers at the new URL. A feed that failed stays as it was
and waits longer before the next try. So does a feed that comes back
without episodes, as some hosts serve one during an outage, with
:fetch/empty-feed in the report. A feed that answers 410, or fails for
about a month, is gone, with :fetch/gone in the report, and isn't
checked again until a Podping or a refresh of that feed asks for it.
Many hosts serve only the newest hundred or so episodes, so the library
keeps the ones that a fetch no longer brings back, and library/episode,
library/show and library/episodes mark each with :retained: when a
fetch first missed it, and :gone? true when the host took it out rather
than leaving it to age out. Show them with the mark, as AntennaPod does,
or hide the gone ones, as Apple Podcasts does. The setting :max-items
caps the episodes of a show, dropping the oldest of those that the feed
no longer brings back, but never one that the listener has progress, a
mark or a place in the queue for. These episodes stay on the device, so
another device only has what the host serves.
A refresh while the device has no network changes nothing. Give
:online? false when the platform says so, e.g. from
navigator.onLine, and nothing is fetched. A refresh in which every fetch
failed before a server answered, at more than one host, counts as offline
too. Either way, no feed or list counts it as a failure, each stays due,
and the report has :refresh/offline, so the first refresh after the
network comes back fetches them all. A failed fetch says why in its
:type, e.g. :dk.simongray.wary-fetch/unresolved for a host whose name
wasn't found.
When to check a feed again depends on its update frequency, its ttl or
the interval between its episodes, as schedule/next-check reads them,
but it's never more than twelve hours away, so that an early or an extra
episode shows up the same day. The setting :cadence changes those
defaults, e.g. {:max-wait 21600} for six hours, and :credentials-of
gives the credentials of a private feed by its URL, so no password is
stored in the library and none goes to another feed.
The library keeps every feed whole, which takes about twice the size of
its XML in memory. A device that never shows the feeds, such as a server
that only syncs and refreshes, can keep a small part of each with the
setting :keep-fn feeds/slim. That part still tells when to check the feed
and which episodes are new.
Podping tells which feeds have changed, so they can be fetched at once. On the JVM, keep the last block that you read and the writers, and poll every few seconds:
(defn podping-step!
"Read the pings after the last result `polled`, and refresh the feeds of
`lib` that they name."
[lib polled]
(let [{:keys [block writers]} polled
polled (podping/poll! block {:writers writers})
guids (feeds/affected (:feeds @lib) (:pings polled))]
(when (seq guids)
(swap! lib library/merge (library/refresh! @lib {:feed-guids guids})))
polled))
(def polled (podping/poll!)) ; starts at the head of the chain
To tell the listener why, feeds/pinged gives the reasons of the pings
of each feed, e.g. #{["live" "podcast"]} when a live stream started.
When no node answers, the result has an :error and the block it got to,
so the next poll continues from there. ClojureScript has no poll!, but
the nodes answer a web page, so read the blocks with podping/call! and
their pings with podping/pings.
A server with a public URL can also have its feeds pushed to it by their
WebSub hubs. The podcast server does this itself, as its
README says. In a
server of your own, keep the hub subscriptions in a map of websub-clj's
next to the library. After each refresh, send the hubs the requests that
library/hub-requests returns:
(def hub-subscriptions (atom {}))
(defn callback-url
"A new callback URL for the hub subscription of a show, which nobody can
guess."
[_feed-guid _subscription]
(str "https://app.example.net/websub/" (websub/token)))
(defn ask-hubs!
"Send the WebSub hubs the requests that the shows of `lib` need."
[lib]
(doseq [[feed-guid request] (library/hub-requests @lib
@hub-subscriptions
callback-url)]
(swap! hub-subscriptions websub/asked feed-guid request (time/now))
(websub/call! request)))
They include a subscription for each show whose feed names a hub, an
unsubscription for each show the listener left, and the renewals. Each
subscription gets a secret of its own, so save the map with
websub/format-subscriptions and keep the text as safe as a password.
Serve the callback URLs with websub/handler, which only passes on a feed
with the hub's signature:
(def callback
(websub/handler
(assoc (websub/handler-options #(deref hub-subscriptions))
:verified! #(swap! hub-subscriptions websub/verified % (time/now))
:denied! #(swap! hub-subscriptions websub/denied % (time/now))
:deliver! (fn [{:keys [request] :as delivery}]
(let [[feed-guid] (websub/subscription-of
@hub-subscriptions request)]
(future
(swap! lib library/pushed feed-guid delivery)))))))
The hub waits for the answer, so :deliver! applies the push on another
thread. A push adds new episodes to the queue as a refresh does, and
:refresh says what changed. Since a hub can push only what changed, a
push never removes an episode; the next fetch does that. Keep refreshing
on the normal schedule too, since a hub can fail.
A network or a critic can publish a list of shows as OPML. When a listener
follows it, the shows that join the list become subscriptions. Fetch the
list with podcast/fetch-list!, which returns it as :list, or an
:error as fetch! does:
(def fetched (podcast/fetch-list! list-url))
(swap! lib library/follow-list list-url (:list fetched)
{:title "Picks" :folder "Picks" :etag (:etag fetched)})
To show the shows of a list before the listener follows it, fetch the
start of each feed with discovery/fetch-head!.
The :etag makes the next fetch of the list conditional. After that,
refresh! fetches the list again once a day. A show that leaves the list
stays a subscription, since the listener may still like it. A show that
the listener unsubscribed from doesn't come back, and a show is
recognised by its feed URL, so it isn't added twice. In the OPML from
profile.opml/to-opml, the followed lists are includes.
Save the library into a store of its own, e.g. after each refresh, and read it back into a new library when the app starts:
(def data (folder/store "data"))
(library/save! @lib data)
;; => {:put ["feed-917393e3-1b1e-5cef-ace4-edaa54e1f810-1759233600000.edn"
;; "segment-f62a6169e37bd339.ciff" "index.edn" "library.edn"]
;; :delete []}
(reset! lib (library/restore! (library/make device-id) data))
This writes the profile, the followed lists and the last sync to
library.edn, and each feed to a document of its own. The search index
has documents of its own too. Only the documents of the feeds and of the
index that changed since the last save are written again, so saving
after each refresh is cheap. The :config can hold functions, so it
isn't saved: give it to make again. When the index is missing or
doesn't read, restore! builds it again from the feeds, and a feed whose
document is missing is fetched again at the next refresh. A document
that didn't read is in the :report under :restore. In ClojureScript,
both functions return promises.
A library of hundreds of shows needn't hold every feed in memory. Restore
it with {:load :none}, or with the guids of the shows on the screen,
and read others back with library/load! when the listener opens them:
(reset! lib (library/restore! (library/make device-id {:config {:store data}})
data
{:load :none}))
(swap! lib library/merge (library/load! @lib data [feed-guid]))
(swap! lib library/merge (library/unload! @lib data :all)) ; e.g. in the background
A feed that isn't loaded is still in the search index and in shows,
with :unloaded? true, and show gives its title and artwork, but no
episode of it is in the views or the download plan, whose files of it
stay. With the :store in the config, a refresh loads the feeds that
it fetches, since it needs the episodes they had, and they stay loaded.
Without it, they aren't fetched, and the report has
:library/not-loaded. The function unload! writes the feeds to their
documents before it lets them go, so nothing is lost if the app stops
before its next save.
The document library.edn names the version of its shape. A library
saved by an older version of podcast-clj is brought up to this one, and
one saved by a newer version isn't read: restore! returns the library as
make made it, with :library/newer-format in the report, and save!
won't write over it, so an older app can't destroy what a newer one
saved.
Any store will do, but give the library one of its own, since save!
deletes the documents of any other index there:
(indexeddb/store "my-app"), or the
origin private file system, (.then (.getDirectory js/navigator.storage) folder/store). A browser can clear both when the disk runs low, unless
the page asked for navigator.storage.persist() and got it. If the
browser clears them, restore! returns the library as make made it,
and the next sync brings the profile back from the other devices.For a show page, library/show gives everything in one map, and for an
episode page, library/episode does. Merge in library/read-files! to
add what the episode's chapters file, transcript and media file say:
(def show-page (library/show @lib guid))
(:notes show-page) ; the description, sanitised, as Hiccup
(:episodes show-page) ; the items in the order of the show
(:links show-page) ; links to the show in other apps
(def page (merge (library/episode @lib id) (library/read-files! @lib id)))
(chapters/at (:document (:chapters page)) 754) ; the chapter at 12:34
(transcript/at (:document (:transcript page)) 754) ; the segment at 12:34
A segment from a WebVTT cue with timestamps inside it, as word-level
transcripts have, also has the timed :runs of its text, e.g.
[{:start-time 1.0 :text "Never "} {:start-time 1.5 :text "drink"}], to
highlight each word as it's spoken.
An episode's id is a pair of strings, [feed-guid item-guid]. For a
route or a key that takes one string, feed.guid/format-episode-id
writes the id as one, and feed.guid/parse-episode-id reads it back.
To colour a page by its artwork, as Apple Podcasts does, give
image/colors the bytes of the image. It returns the colours of a UI,
and in ClojureScript a promise of them:
(image/colors (:body (http/send! {:url (:artwork show-page) :as :bytes})))
;; => {:background "#a90000" ; for the artwork of Podcasting 2.0
;; :text "#ffffff"
;; :accent "#fefefe"
;; :accent-text "#000000"
;; :complement "#00a9a9"
;; :palette ["#a90000" "#fefefe" "#bd3e3a" "#cf706c" "#e0a09e"]}
The background is the colour that most of the image has, and the text
is black or white, whichever reads better on it. The accent is the next
colour of the image that stands apart from the background enough for a
control, or else the complement of the background, or else the text
colour. For one of them alone, there's image/dominant-color,
image/palette, image/complementary-color and image/contrast-color.
The platform decodes the image: ImageIO on the JVM and the browser in ClojureScript, where you send the request through the proxy. Node has no decoder, so the colours are nil there.
To keep the chapters files and transcripts on the device, set :cache
in the config to a document store of like-minded, e.g. one in IndexedDB.
A file it keeps is asked for again with its ETag, so an unchanged one
costs next to nothing, and it's read from the store when the request
fails, e.g. offline.
The chapters come from the first place that has them: the chapters file,
Podlove chapters in the feed, the media file, or timestamps in the show
notes, and :source says which. A file that couldn't be read leaves an
:error in its part. The duration is read from the media file, and a host
that inserts ads can send a different file each time, so it can differ
from the feed. For an HLS stream, :media is what its playlists say: the
variants with their bitrates, sizes and codecs, the renditions such as
subtitles, the duration, and :video?.
The option :except leaves out the parts that a page doesn't need, e.g.
the rows of an episode list are much cheaper as (library/episode @lib id {:except #{:notes :text :chapters :links}}).
For an episode with video, :media-kinds says whether it offers
:audio, :video or both, :poster is the picture to show before the
video plays, and :content-links are the places outside the feed where it
can be watched, e.g. on YouTube.
Links and images in the notes are made absolute against the web page of
the episode or the show, and a link written as a bare domain, as many
feeds do, gets https://. To do it your own way, give :notes-url-fn a
function of the page and the URL, which returns the URL to keep or nil.
To make the times in the notes, e.g. 12:34, play the episode from there,
give :timestamp-fn a function of the seconds and the text of a time.
It returns the Hiccup in its place, or nil to leave the text, e.g.
(fn [seconds text] [:a {:href (str "#t=" seconds)} text]). A time in a
link, or past the end of the episode, stays text.
For the shows that a show recommends in its podroll, and the feed of its
publisher, merge in library/fetch-linked-shows!. It reads each show of
the podroll from the start of its feed, most often the first 64 KB, or
from the Podcast Index when the config has its credentials as :index.
Only show the publisher's other shows when :confirmed? is true, since
the publisher's feed must list this show too:
(def linked (library/fetch-linked-shows! @lib guid))
(map (comp :rss/title :show) (:podroll linked))
(when (:confirmed? (:publisher linked))
(:shows (:publisher linked)))
Without app.library, show/view and episode/view take a parsed feed
directly. Where the vocabularies overlap, resolve/properties gives one
value for each property, and each accessor of feed.resolve, such as
resolve/transcript or resolve/podroll, gives one of them on its own.
A feed can name a thread of comments about an episode on ActivityPub, the
AT Protocol or Nostr, and comments/thread picks the first one that
podcast-clj can read. It returns nil when there is none, or when the
podcaster turned comments off. For the AT Protocol, give the AppView to
ask for replies:
(def opts {:appview "https://public.api.bsky.app"})
(def thread
(some-> (comments/thread show item opts) (comments/fetch! opts)))
(comments/tree thread)
;; => {:content "<p>…</p>" :author {:name "…" …} :replies [{…} …]}
The content of a comment is sanitised HTML. To link mentions and hashtags
to pages of your app, render it with comments/hiccup and a :link-fn:
(defn comment-link
"The URL in this app of the mention or hashtag `part` of a comment."
[{:keys [type protocol id tag]}]
(case type
:mention (when id (str "/profiles/" protocol "/" (url/percent-encode id)))
:hashtag (str "/tags/" (url/percent-encode tag))
nil))
(def reply (first (:replies (comments/tree thread))))
(comments/hiccup thread reply {:link-fn comment-link})
;; => [[:p {} "Good episode, " [:a {:href "/profiles/activitypub/…"} "@ann"]
;; " " [:a {:href "/tags/podcast"} "#podcast"]]]
For a notification, comments/text gives the content as plain text, and
comments/parts gives the parts as data. Show a comment with a :summary
or :sensitive? behind a warning. On Bluesky, the author of the root post
can hide replies, and tree only shows those with {:hidden? true}.
To link to the original, use the :url of a comment or an author, which
everything but Nostr has. On Bluesky, it's a page on bsky.app, and
atproto/post-url gives the page on another web app, e.g. (atproto/post-url (:id reply) {:origin "https://deer.social"}). A comment's :counts are its
replies, reposts, likes and quotes, as far as its server counts them. On
Nostr, they're the likes and reposts that the relays gave, one for each
author.
A fetch reads for a minute at most, and 100 comments at a time. To show
something sooner, give it :max-seconds 5, show what it read, and fetch
the same thread again to read more. A server that failed for a while is
asked again on the next fetch. To see new comments, start again from
comments/thread, and compare the two readings with comments/changes,
which gives the comments :added, :edited and :deleted since, e.g.
for a notification of new replies.
Some fediverse servers answer only signed requests, e.g. Mastodon with
authorized fetch, and GoToSocial. When the root post is on such a server,
fetch! reads the thread through its Mastodon API, which only has the
replies that server knows, and leaves out the replies on such servers. In
a browser, the podcast server's proxy can sign for you, as its
README
says. Elsewhere, sign with
httpsig-clj and the key of an
actor that you publish:
(def send-signed!
(httpsig/sender comments/send! actor-key
{:signers [cavage/sign httpsig/sign]}))
(comments/fetch! thread {:send send-signed!})
This signs with draft-cavage, which every fediverse server takes, and signs again with RFC 9421 for a server that refuses it. A server checks the signature by fetching the actor and looking up its account with WebFinger, on port 443 of the actor's domain.
On Nostr, people also write about episodes that name no thread, and
nostr-comments/about gives the thread of those notes, for an episode or
a show:
(def relays ["wss://relay.fountain.fm" "wss://relay.damus.io"])
(def thread
(comments/fetch! (nostr-comments/about show item) {:relays relays}))
(:replies (comments/tree thread))
Its root is the episode itself, so the root of the tree has no content.
The podcaster didn't choose this thread, so show nothing when
resolve/social-interacts gives the protocol disabled. Nostr has no web
pages, so a mention has no link unless your :link-fn returns one, such
as its :uri, a nostr: URI that opens a Nostr app on the device.
A listener can comment, reply and react on Nostr with their own key, through a signer. Make the event, and post it to the relays of the thread:
(def signer
(signer/local (signer/secret-key nsec))) ; nsec1… that the listener gave
(nostr-comments/post! thread
(nostr-comments/comment-event thread "Great episode!")
signer)
;; => {:event {…} :relays {"wss://relay.fountain.fm" {:accepted? true} …}}
The other events are reply-event to a comment, reaction-event and
delete-event. A comment on an episode is of kind 1111 of NIP-22, or of
kind 1 with {:kind 1}, as Fountain writes it. To see a post in the
thread, fetch the unread thread again.
There are three kinds of signer:
signer/local holds the key in the app. Make a new key with
signer/generate-key!, and store it as you would a password. The
listener can keep it, or take it to another app, as signer/nsec.signer/nip07 signs with a browser extension such as Alby or nos2x, in
ClojureScript, and the page never sees the key.signer/remote asks a remote signer of NIP-46, such as nsec.app, to
sign. Read the bunker:// URI that the listener gives with
signer/parse, add a key of the app as :client-key, and keep both.
Then signer/connect! once, which returns the listener's public key.A remote signer can ask the listener to approve a request on a web page
first, and the answer is then :nostr/signer-approval with the :url.
Show the address of the page before you open it, and ask again once the
listener approved. Only the bunker:// URI is supported, since the
nostrconnect:// URI needs a subscription that waits for the signer.
The profile holds the queue and the progress, and you control the media
element. Send each event of the media element to library/step, and do
the effects it returns:
(swap! lib library/update-profile profile.queue/enqueue id)
(:effects (swap! lib library/step [:progress id 1234 3600])) ; => []
(:effects (swap! lib library/step [:skip id :forward]))
;; => [[:seek id 1264]]
(:effects (swap! lib library/step [:ended id]))
;; => [[:play next-id]]
The effects are [:seek id position], [:play id], [:stop id] and
[:played id]. In ClojureScript, a handler can look like this:
(defn apply-effects!
"Do the `effects` of a step on the media element `audio`."
[audio effects]
(doseq [[kind id position] effects]
(case kind
:seek (set! (.-currentTime audio) position)
:stop (.pause audio)
:play (play-episode! audio id)
:played nil)))
To start an episode, library/playback gives what the player needs: the
file, where the listener left it or the end of its intro, the show's
rate, the title and artwork, the chapters, a transcript for captions, and
the metadata of the Media Session, which a browser shows on the lock
screen.
The page's Media Session is set by play-nice, a library that you add to your app yourself, since podcast-clj doesn't depend on it:
dk.simongray/play-nice {:mvn/version "0.1.0"}
With its session namespace, the buttons on the lock screen and the
headphones send their events through library/step too:
(defn play-episode!
"Start the episode `id` of the library in the media element `audio`."
[audio id]
(let [{:keys [src position rate metadata]} (library/playback @lib id)
step! #(apply-effects! audio (:effects (swap! lib library/step %)))
press! (fn [details]
(run! step! (media-session/events
@lib id (.-currentTime audio) details)))]
(set! (.-src audio) src)
(set! (.-playbackRate audio) rate)
(.addEventListener audio "loadedmetadata"
#(set! (.-currentTime audio) position)
#js {:once true})
(session/metadata! metadata)
(session/actions! (zipmap (media-session/actions @lib id)
(repeat press!)))
(.play audio)))
The lock screen follows the media element by itself, so it shows how far
the episode has come. The next and previous buttons go to the chapters
of the episode by default. The listener's preference
:track-buttons can send them to the episodes of the queue instead,
with :queue, or to the queue once no chapter is left, with
:chapters-first. For buttons of your own, the events
[:next id] and [:previous id] of library/step skip to the next
episode of the queue and go back to the one heard before.
With the listener's preference :continuous?, an episode that ends with
nothing else queued is followed by the first unplayed episode of its
show in its listening order: the next of a serial show, and the newest
of an episodic one. A sleep timer still stops the player. After a pause,
e.g. a call, playback starts a few seconds before where the listener
left off, by the preference :rewinds.
The file is the downloaded one when the library's record of the files
has it, as the next section shows, or when the config's
:local-file-fn gives one for the episode's id, e.g. an object URL. Else
it's the enclosure. Once library/read-files! has read the episode's
chapters file, give its result to playback as :read, and the
metadata has those chapters too.
To pick the file to play yourself, use resolve/enclosure, e.g. with
{:types ["audio/opus" "audio/mpeg"] :max-bitrate 64000}. To keep to
audio when a show also offers video, add
:playable-pred resolve/audio?, which leaves out a stream that doesn't
say what it holds. The listener chooses between the audio and the video
of an episode that offers both with the preference :video?, or the
show's setting of that name, and library/episode and library/plan
follow it.
To keep to what the browser can play, e.g. Opus or a video codec, ask
it about each enclosure. The capabilities namespace of play-nice asks
Media Capabilities, and canPlayType where that says no, about what
resolve/decoding-configuration gives:
(defn playable-pred
"A predicate of an enclosure by the answers `infos` of the browser, which
takes one whose type says nothing of its format as playable."
[infos]
(fn [enclosure]
(if-let [configuration (resolve/decoding-configuration enclosure)]
(capabilities/playable? (infos configuration))
true)))
(-> (capabilities/decoding-infos!
(keep resolve/decoding-configuration (resolve/enclosures item)))
(.then (fn [infos]
(resolve/enclosure item {:playable-pred (playable-pred infos)}))))
A file without a type, or with one such as application/octet-stream,
is almost always an MP3, so it counts as playable. If the browser can't
play it after all, the audio element fires an error when it starts.
To queue an episode of a show that the listener isn't subscribed to, e.g. from a search or a link, add it to the library first:
(let [fetched (podcast/fetch! url)
id [(feed.guid/feed-guid (:feed fetched) url) item-guid]]
(swap! lib #(-> (library/add-episode % url fetched item-guid)
(library/update-profile profile.queue/enqueue id))))
The profile keeps the episode as a remote item with the URL of its feed,
as podcast:remoteItem names an episode, so refresh! fetches that feed
on every device. The library keeps only that episode of the show, and
doesn't check the feed again, so the show's later episodes never count
as new. To take it out again, use library/remove-episode, which leaves
the queue as it is. And library/remove-played-episodes takes out every
such episode that the listener played, from the queue and the playlists
too, but keeps those with a star or another mark.
A show's live streams, its podcast:liveItems, play by their ids as its
episodes do. The function library/live-items lists those of the
subscriptions, the ones on air first, then the ones to come, and a feed
is checked again at the start and the end of each one to come.
Some settings and events change what step does:
:skip-outro 45 in the settings of a show,
its episodes end 45 seconds early, and step returns [:stop id] and
then the effects of an ended episode.[:chapter id :next doc] and
[:chapter id :previous doc] jump between the chapters of doc.:sleep in the library, e.g.
(swap! lib assoc :sleep {:episodes 2}), to stop the player when two
more episodes have ended. The timer belongs to this device, so it isn't
synced.:played-threshold and :listening-gap change both.The browser player of the inspector, in web/inspector/player.cljs, is a
working example.
(library/plan @lib)
;; => {:download [{:id [guid "ep-3"] :url "https://…" :reason :rule …}]
;; :delete [{:id [guid "ep-1"] :bytes 50117331 :reason :played}]
;; :waiting []}
The plan says which episodes to download and which files to delete, by the library's record of the files on this device. Downloading, deleting and choosing when to do it are up to you, since podcast-clj knows nothing about the network or the battery. Make a plan after each refresh, and when the listener plays or queues an episode.
The rules live in the profile, so they sync: the listener's rules are
the preference :downloads, and the rule of a show is the setting
:downloads of its subscription. A rule selects episodes with the options
of criteria/episodes:
(def show-rule
{:download? true
:episodes {:where {:duration [600 nil]} :limit 3}})
(swap! lib library/update-profile
(fn [profile at]
(-> profile
(profile/prefer {:downloads {:default {:played 86400}
:queue 3
:max-episodes 50}}
at)
(profile/configure guid {:downloads show-rule} at))))
Here a played file goes after a day, the first three episodes of the queue
are downloaded, and the show gets its three newest episodes of ten minutes
or more. Every key is in downloads/default-rules, and those of a show's
rule in downloads/default-rule. To download across all shows, name
saved filters of the profile in :filters, with the most episodes of
each, e.g. {"Unplayed" 5}, and their episodes come after the queue.
To follow the plan:
:download in order. Record the start with
library/start-download, and the end with library/finish-download
and the :path and :bytes of the file.:delete, and record it with library/remove-file,
which keeps its entry so that it isn't downloaded again.:waiting, which didn't fit within
:max-episodes or :max-bytes, or was a file above :max-file-bytes.
Stop a download that runs past :max-file-bytes yourself, since a feed
can give the wrong length.(defn download!
"Download the `entry` of a plan with `save!`, your own function that
writes the file at a URL to the device and gives its path and bytes."
[entry save!]
(swap! lib library/start-download entry)
(let [{:keys [path bytes]} (save! (:url entry))]
(swap! lib library/finish-download (:id entry) {:path path :bytes bytes})))
When a download fails, record it with library/fail-download, with the
bytes that came and the ETag, Last-Modified and Date of the answer. The
plan lists it again, at once the first time and later after each failure
that follows. An entry of the plan with :received goes on from those
bytes. Send its :headers with the request, and downloads/resume-result
says what the answer means: append its body, replace the bytes with it,
keep them as the whole file, or start over.
Once a download has finished, library/playback plays the file. To read
what the file says about itself, e.g. its chapters, give media/read! a
fetcher of the file as :fetch: ranges/file-fetcher of its path on the
JVM, or ranges/blob-fetcher of a File or a Blob in a browser. Besides
the chapters, the duration and the artwork, the result has the file's
:tags, e.g. its title and artist, and the :bitrate, :sample-rate
and :channels of its audio.
A file that the listener marks with library/keep-file stays whatever
the rules say, and downloads/usage of (:files @lib) returns the
episodes and bytes of each show on the device, e.g. for a storage screen.
The record is saved with the library, but never synced, since each device
has its own files. To keep a record of your own instead, give it to
plan as files, e.g. {[guid "ep-1"] {:bytes 50117331 :at #inst "2026-09-09"}}. For rules that only apply to this device, give plan the
rules as :rules. To choose among the files of an episode, e.g. audio
alone, give it the options of resolve/enclosure as :enclosure. The
plan only has files that resolve/downloadable? takes, so never an HLS
stream or a torrent. A rule written by a newer version of podcast-clj
does nothing until this version understands it, and downloads/problems
says why.
The plan deletes the files of a show that the listener left, but for
those that the rules keep and the episodes added on their own, unless
the rules have :delete-unsubscribed? false. To name a file, give
downloads/file-name the entry of the plan and the title of the episode.
It returns a name that every file system takes, from the letters of the
title, a digest of the episode's id and the extension of the file, e.g.
Episode-274-Vibe-Cloning-c71b5f4a.mp3.
In a browser, a host rarely lets a page read an episode, so save it
through your server. Mount wary-fetch's proxy/stream-handler, which
passes the file on as it arrives, and send the request through it with
(proxy/sender "/media") as :send and a :sink that writes the file,
e.g. to the origin private file system. The podcast server serves such a
route at /media, for the files of the listener's episodes alone. In
Chromium, Background Fetch goes on after the page closes: give
registration.backgroundFetch.fetch the URL of the route, e.g.
/media?url=…, and store the response in the service worker.
No standard defines a link to a show in an app, so links/links writes
the link for each app that the show's ids are enough for. A show from
directory/search! has the most ids, so it gets the most links:
(def ids (links/ids (first found) item))
(links/links ids {:only #{:apple :pocketcasts :antennapod}})
;; => [{:app :antennapod :name "AntennaPod" :type :app
;; :show "https://antennapod.org/deeplink/subscribe?url=…"
;; :subscribe "antennapod-subscribe://…"} …]
A show that the listener subscribed to by its URL has only the ids of its
feed, so library/identify! asks the directories for the rest: the
Podcast Index by its guid or its URL when the config has the
credentials, and Apple by its title. The ids stay in the library, and
the views of the show and its episodes link to it in many more apps:
(swap! lib library/merge (library/identify! @lib))
A :subscribe link opens the app, so only show it on a device that has
the app. The link formats change, so give your own table as :apps when
one is wrong. For a link to a moment in an episode, links/at-time puts a
media fragment on the URL of the media file:
(links/at-time (:url (resolve/enclosure item)) 754 796.5)
;; => "https://example.org/54.mp3#t=754,796.5"
The library keeps a full-text index of every feed:
(library/search @lib "podcasting 2" {:limit 10})
;; => [{:id [feed-guid "https://example.com/ep0002"]
;; :score 0.16
;; :stored {:title "Episode 2 - The Present" …}
;; :feed {…}
;; :item {…}}
;; …]
The results are the episodes and shows, best first, and :offset 10
gives the next page. The last word counts as a prefix, so this works for
search as you type. A query can also have:
"a phrase", whose words must come in a row, though in the notes they
can be anywhere, since the index holds the notes without the positions
of their wordsOR or | between words, NOT, ! or - before one, and parenthesesword~ for a word with typostitle:word or notes:word for one fieldThe docstring of search.queries/parse has the rest. The index takes
about a kilobyte per episode, so 50 shows with 28,000 episodes take
about 30 megabytes. A result holds what a list of results shows, and its
id names the show and the episode. To leave the index out, e.g. on a
watch, give :search? false in the config.
A word that no episode holds also matches words with typos: one in a
word of three to five letters, and two in a longer one, which the option
:typo-lengths changes. With :fuzzy true, every word can have typos,
and with :fuzzy false, only a word~ can. The words that matched with
typos are under :typos in the metadata of the results, and in each
result that holds a word only with typos, so that the UI can say "Near
matches for futrue":
(def found (library/search @lib "futrue"))
(:typos (meta found)) ; => {"futrue" ["future"]}
(:typos (first found)) ; => {"futrue" ["future"]}
To search only some episodes, give the criteria of library/episodes as
:where, which leaves out the shows:
(library/search @lib "podcasting" {:where {:status :unplayed}})
To show the matches of a result, cut a snippet of its text with
library/snippet. It takes the notes when they match, since a list of
results shows the title anyway, and :width sets the number of
characters, 160 by default:
(def q "\"podcasting 2.0\"")
(library/snippet @lib (first (library/search @lib q)) q)
;; => {:field :description
;; :parts [{:text "A look into the future of podcasting and how we get to "}
;; {:text "Podcasting 2.0" :match? true}
;; {:text "!"}]}
To find where words are said in a transcript, use transcript/matches.
Each passage that matches has the time of the cue where it starts:
(transcript/matches (:document (:transcript page)) "trailer")
;; => [{:start-time 2.76 :speaker "Sarah" :body "…" :parts […]} …]
Building the index takes a few seconds for 50 shows, so library/save!
saves it too, as Keep the library between
runs shows.
Without app.library, build the index from the feeds yourself, with the
documents of feed.search, and search it with
drop-in-search:
(defn index-feeds
"A search index of the shows of `feeds`, a map of `{:url :feed}` entries,
and of their episodes."
[feeds]
(feed.search/index (mapcat #(feed.search/feed-documents (:feed %) (:url %))
(vals feeds))))
(search/query (index-feeds feeds) "podcasting 2" {:limit 10})
When a feed changes, update the index with feed.search/with-feed, and
take a show out with feed.search/without-feed. To make transcripts
searchable, give them to feed.search/item-document. To keep the index,
ciff/plan gives the files to put into a store and the names to delete
there, and ciff/read-files reads them back.
The profile also holds how the listener organises shows and episodes, and it syncs like the rest:
(swap! lib library/update-profile
(fn [profile at]
(-> profile
(profile/configure guid {:folder "News" :inbox? true} at)
(profile/tag guid "Daily" at)
(profile.marks/star id at)
(profile.marks/bookmark id 754 "The interview starts" at)
(profile.marks/rate-episode id 5 at)
(profile.marks/clip id 754 42.5 "The best answer" at)
(profile.queue/add-to-playlist "Road trip" id at)
(profile/save-filter "Starred"
{:where {:starred? true :status :unplayed}
:order :newest}
at))))
Each has a function to read it back, such as profile/folders,
profile/tagged, profile.queue/inbox, profile.marks/starred,
profile.marks/bookmarks, profile.marks/clips and
profile.queue/playlist. To show the size of the inbox on the app's
icon, give (count (profile.queue/inbox profile)) to
navigator.setAppBadge in a browser, or to the platform's badge.
For a screen of the shows, library/shows gives each subscription in
the listener's order, with its title, its artwork, when its newest
episode came out, and how many of its episodes are unplayed, in
progress, played, in the inbox and queued. The shows are in the order of
their titles until the listener moves one with profile/move-show. The
settings :custom-title and :order of a show replace the title of its
feed and its listening order, on its page, in library/episodes and in
OPML.
To take an episode out of the lists without marking it played, hide it
with profile/hide. A show's setting :hide-where hides those of its
episodes that meet the criteria, e.g. {:status :played} hides the ones
heard, and profile/unhide shows one again. Both library/episodes and
the download rules leave the hidden episodes out unless the criteria name
:hidden?, e.g. {:hidden? true} lists them. To take an episode out of
the history, profile/forget drops its progress and its played mark on
every device, so it counts as never heard until the listener plays it
again.
To change many episodes at once, e.g. to clear the inbox or the queue,
profile/mark-all-played, profile/mark-all-unplayed,
profile.queue/dismiss-all, profile.queue/enqueue-all and
profile.queue/dequeue-all take the ids, and write them with one stamp.
To mark every episode of a show as heard, use library/mark-show-played.
profile.opml/to-opml, and profile.opml/from-opml reads
them back, including the OPML of other apps.from-opml, which
leaves out those marked as left, refresh, and then
library/episodes-from-opml takes the episodes over by the URLs of
their media, and library/to-opml writes them out in the same form.:scale takes another one, such
as {:worst 0 :best 1} for thumbs. Each rating keeps its scale, so
profile.marks/rating can give it in yours.profile.queue/to-list-feed, so other Podcasting 2.0 apps can play it.profile/listened, for
statistics. Report the position at least every 30 seconds while an
episode plays, or it isn't counted.Besides what an episode is, its criteria can name its show, the show's
folder and tags, the queue and the state of its file on the device, e.g.
{:where {:folder "News" :download :downloaded}} for the downloaded
episodes of the shows in News. The docstring of criteria/where lists
them all. To read the episodes of some shows alone, e.g. for the page of
a show, give library/episodes their guids as :feed-guids.
A saved filter holds the options of library/episodes. Another device may
have saved it with a newer version, so check it with criteria/problem
first:
(let [saved (get (profile/filters (:profile @lib)) "Starred")]
(when-not (criteria/problem saved)
(library/episodes @lib saved)))
Each device writes its whole profile as one document to a store, and reads the documents of the other devices. The stores and the sync are those of like-minded, and the profile they sync is a CRDT of mind-meld. Syncing explains why this is safe.
(def creds {:username "simon" :password (secret/hide "app-password")})
(def root "https://cloud.example.org/remote.php/dav/files/simon/podcasts/")
(def cloud (webdav/store root {:credentials creds}))
(store/check! cloud)
;; => {:ok? true :checks [{:id :store/reachable …} …]}
(def backends
[(library/backend cloud {:opml? true :app "my-app"})
(library/backend (folder/store "/Users/simon/Dropbox/Podcasts")
{:app "my-app"})])
(swap! lib library/merge (library/sync! @lib backends))
(library/next-sync @lib) ; when to sync again
A store can be a WebDAV collection, which like-minded reaches through
webdav-clj, or a folder that a
sync client such as Dropbox mirrors. The providers
page
of like-minded has the URLs and paths of some of them. With
library/backend, a store becomes the backend of a sync: :app names
your app in the documents, and :opml? also writes the subscriptions as
OPML for other apps.
Sync when the app starts, when it goes to the background, and after a
series of changes. After any change, the position of a playing episode
included, library/next-sync asks for a sync within seconds, so while an
episode plays, sync when it pauses instead. A button in the settings
screen can test a store with store/check!.
A sync also moves each played episode that nothing changed for a year to
the archive of its show, so that the documents stop growing with every
episode played. It still counts as played, but its position and the time
it was played go. For another span, set :archive-after in the config,
in milliseconds.
To keep the documents encrypted on the server, wrap the store with
encrypted/store and a passphrase, or a function that returns one. Use
:except to leave the subscription list readable for other apps:
(library/backend (encrypted/store cloud
from-keychain
{:except #{library/opml-name}})
{:opml? true :app "my-app"})
Each sync removes the tombstones that every device has seen, once they're
30 days old. A device that stopped syncing blocks this. Such devices are
listed by crdt/lagging of mind-meld, and crdt/retire removes one.
When the listener turns sync off on a device, call library/leave!
with the backends. It retires the device and writes that through each
backend, so the other devices stop waiting for it and delete its
document. A device that left can't sync under its id again, so to turn
sync back on, give the library a new id with library/with-device:
(swap! lib library/merge (library/leave! @lib backends))
;; later, to sync again
(swap! lib library/with-device (str (random-uuid)) {:name "Phone"})
To share the subscriptions and the progress of episodes with AntennaPod,
Kasts and other apps of the gpodder API, add a gpodder server as a backend.
The API carries nothing else, e.g. no queue or stars. For the GPodder Sync
app of Nextcloud, give :api :nextcloud:
(gpodder/backend "https://gpodder.net"
{:credentials creds
:identities-fn #(gpodder/identities (vals (:feeds @lib)))})
To serve a store from your own Clojure server, mount the Ring handler of
store.webdav.server, and for the gpodder API that of
gpodder.server. Each serves one listener, so give each listener a
handler of their own behind your login, as their docstrings show. To sync
through a service that has no store yet, write one: like-minded's
reference
gives the contract, a map of one to four functions.
In a browser that can pick a folder, give its handle to folder/store.
The browser asks the listener for permission, and only during a click:
(defn pick-folder!
"Ask the listener for a sync folder, and give a promise of its backend."
[]
(.then (js/showDirectoryPicker #js {:mode "readwrite"})
#(library/backend (folder/store %) {:opml? true :app "my-app"})))
Keep the handle in IndexedDB for the next visit, since it can't go in
EDN. The permission can lapse between visits, so check it before a sync.
If it's :prompt, show a button that renews it:
(folder/query-permission! handle) ; promises :granted, :prompt or :denied
(folder/request-permission! handle) ; from a click
A browser without a folder picker can still sync through the folder by
hand. Let the listener save this device's document into the folder, and
load the folder with <input type="file" webkitdirectory>:
(library/document @lib) ; => {:name "8f1c….edn" :body "{:version 2 …}"}
(.then (library/sync! @lib [(library/backend (folder/store (.-files input)))])
#(swap! lib library/merge %))
The store of the files of an input has no :put, so its backend only
reads them. Check for the picker itself rather than for a browser, so
that the app uses it wherever it arrives. The providers
page
of like-minded has what each browser can do, and the inspector's player
in web/inspector/player.cljs does all of the above.
On a website, each browser is a device with a library of its own, and your server keeps each listener's documents behind your login. A page that syncs with its own origin sends the cookie of your login along, and needs no CORS:
;; on the server, a folder of documents for each listener
(defn dav
"The WebDAV handler of the listener who sent `request`."
[request]
(let [store (folder/store (str "sync/" (user-id request)))]
((webdav-server/handler store "/dav/") request)))
;; in the page
(library/backend (webdav/store (str js/location.origin "/dav/")))
Here user-id is your own function that gives the id that your login
found for the request. It goes into a path, so never use a name that the
user typed. Mount dav for /dav and everything below it, as the
inspector does in web/inspector/service.clj. A table in your database
can take the place of the folders, as a store of your own.
In the page:
(indexeddb/store (str "my-app-" account-id)).navigator.locks, and before each
step, merge in the library that the last step saved, since the tabs
share the device id.crdt/lagging lists, since private
windows and cleared site data leave devices that never sync again.The value block of an episode is the :value of its view. A time split
in it can pass a stretch of the episode, e.g. a song, to the recipients
of an item in another feed. Fetch the value blocks of those items with
library/fetch-remote-values! when the episode starts, rather than at
each payment of a stream. Then value/recipients-at gives who is paid at
a position, and value/payments divides an amount between them:
(def page (merge (library/episode @lib id)
(library/fetch-remote-values! @lib id)))
(def paid (value/recipients-at (:value page) 754 (:remote-values page)))
(value/payments (:recipients paid) 1000)
The blocks come from the Podcast Index when the config has the :key
and :secret of an account as :index, and else from the feed URL of
the remote item, which few feeds give. When a remote item has no block,
the episode's own recipients get its share. A remote item whose block
couldn't be fetched is in :remote-report, and until you fetch it again,
only the fees of its split are paid.
Without app.library, episode/fetch-remote-values! takes the view
from episode/view. To fetch a block yourself, e.g. with
podcastindex/fetch-remote-value!, which returns the block, nil or an
:error, fold it in with value/with-remote.
What goes with a payment is one map, however it's sent. Inside a remote
split, (:remote-item (:remote paid)) is the remote item, so add its
:feed-guid and :item-guid as :remote-feed-guid and
:remote-item-guid:
(def payment
{:action :boost
:sats 1000
:position 754
:message "Great episode"
:feed-guid guid
:item-guid (feed.guid/item-guid item)
:app-name "My app"})
Pay a recipient of type node with keysend, and give the wallet the
custom records of value/records. If its report has
:value/custom-record, don't send the payment, since it would reach the
node but not the recipient's wallet:
(def records (value/records (assoc payment :sats 50) recipient))
(value/report records) ; => [], or :value/custom-record
Pay a recipient of type lnaddress with an invoice from
lnaddress/request-invoice!, which also checks its amount. Put the
payment's metadata in the comment, by serving the header of
metadata/format-header at a URL of your own and naming that URL:
(def invoice
(lnaddress/request-invoice!
"ann@getalby.com" 50000
{:comment (fn [{:keys [comment-length]}]
(metadata/format-comment payment url comment-length))}))
;; => {:invoice "lnbc500n1…" :msat 50000 :comment "rss::payment::boost …"}
{"x-rss-payment" (metadata/format-header payment)} ; the header to serve at url
If the comment doesn't fit, none is sent and the invoice has no
:comment, so tell the listener before paying. Don't pay when an answer
has an :error, e.g. an invoice for another amount. The metadata page is
public, and so are the sender's name and message.
No wallet pays an invoice after its :expires-at, so check it with
bolt11/expired? before paying, and ask for a new one once it has
expired. With the listener's consent, the :payer option sends their
details to a service that takes them, e.g. {:name "Ann"}. When a wallet
doesn't say whether it paid, lnaddress/verify! asks the service, if the
invoice has a :verify URL:
(bolt11/expired? (:invoice invoice) (time/now)) ; => false
(lnaddress/verify! invoice) ; => {:settled? true :preimage "…"}
A listener can connect their own wallet with a Nostr Wallet Connect URI. The URI holds a secret key that can spend from the wallet, so store it as you would a password:
(def connection (nwc/parse uri)) ; the secret prints as #<secret>
(def info (nwc/fetch-info! connection))
;; => {:methods ["pay_invoice" "pay_keysend" …] :encryption ["nip44_v2"] …}
(nwc/ask! connection (nwc/pay-keysend (assoc payment :sats 50) recipient)
{:info info})
;; => {:method "pay_keysend" :preimage "…" :fees-msat 1000}
(nwc/ask! connection (nwc/pay-invoice (:invoice invoice)) {:info info})
;; => {:method "pay_invoice" :preimage "…" :proven? true :fees-msat 1000}
The text to store comes from nwc/format. When :proven? is false, the
wallet says it paid but gave no proof, so don't pay again, and tell the
listener instead. An answer with an :error says whether the request went
out:
| Error | Sent? | What to do |
|---|---|---|
:value/wallet-unsupported | No | Pay another way. The wallet doesn't take the command. |
:value/command-too-large | No | Shorten the listener's message. |
:value/wallet-unreachable | No | Try again later. |
:value/wallet-refused | Yes | Show the :reason. The wallet didn't pay. |
:value/wallet-silent | Yes | Don't pay again. Wait until :expires-at, then ask with nwc/lookup-invoice. |
:value/wallet-unreadable | Yes | Ask the wallet before paying again. |
To keep the listener's money safe, show the recipients and amounts before
paying, since the feed chooses them. Show texts from the wallet as text,
not HTML. Ask the listener before using the :lud16 of a connection, and
suggest giving the connection a budget in the wallet.
In a browser, a wallet extension such as Alby can give the page a WebLN
provider, which value.webln calls in ClojureScript. It counts keysend
payments in whole sats:
(webln/pay-keysend! (assoc payment :sats 50) recipient)
(webln/pay-invoice! (:invoice invoice))
;; => a promise of {:preimage "…" :proven? true}
To pay a boost to every recipient, give recipients/pay! the wallet's
functions. It divides the amount as value/payments does, pays a node
with keysend and a Lightning address with an invoice, one at a time, and
returns an entry for each recipient:
(defn record!
"Record the `entry` of a payment in the profile of the library."
[entry]
(swap! lib library/update-profile profile.payments/record entry))
(recipients/pay! (:recipients paid) 1000 (assoc payment :group (str (random-uuid)))
{:keysend-fn webln/pay-keysend!
:invoice-fn webln/pay-invoice!
:sending-fn record!})
;; => [{:id "b7e1…/0" :recipient {…} :sats 990 :status :paid :preimage "…"}
;; {:id "b7e1…/1" :recipient {…} :sats 10 :status :skipped :error {…}}]
The :sending-fn records each payment just before it goes to the wallet,
so record the entries of the result too. A payment that
profile.payments/unsettled still lists after a crash, or on another
device after a sync, may be paid, so ask the wallet about it rather than
paying it again. With NWC, the functions are nwc/ask! of the commands
above, and :invoice-opts holds the options of
lnaddress/request-invoice!, e.g. the :comment with the metadata.
As a host, you can read what listeners sent with their payments. A keysend
payment carries the record 7629169, which value/parse-boostagram reads.
A payment to a Lightning address has a comment instead, and the metadata
it points to:
(value/parse-boostagram (get custom-records 7629169))
(some-> (metadata/parse-comment memo) :url metadata/fetch!)
(value/as-received payment {:msat 25000 :address "ann@getalby.com"})
The sender writes both, so they're only claims, and value/as-received
keeps the amount the node received and reports any claim that differs.
Remove duplicates by :id, since a sender can point to the metadata of
another payment, and show the texts as text, not HTML.
To validate a feed, e.g. one that a podcaster submits, fetch it with
fetch! and give the answer to validate/report, with the requirements of
PSP-1, Apple Podcasts, Spotify or YouTube. It returns the diagnostics of
the fetch, the parse and the validation in one list, and diagnostics/verdict
says whether the feed passes:
(def diagnostics
(validate/report (podcast/fetch! url) {:requirements :apple}))
(diagnostics/verdict diagnostics)
;; => {:passed? false
;; :level :error
;; :counts {:error 2 :warning 187 :info 373}
;; :errors ({:id :validate/unknown-category
;; :message "Apple Podcasts doesn't list the category"
;; :count 1 …} …)}
A feed that can't be read fails as a whole, e.g. on a 404, or when the document is an Atom feed. An error breaks a rule of a spec or of the requirements you chose, a warning is something that apps are likely to show worse than the podcaster means, and an info needs no change, as Diagnostics explains. The requirements raise some levels, e.g. a repeated enclosure URL is a warning, but an error for Apple, which keeps only the first of the items. YouTube makes a video of each episode, so it takes only audio, and a title of at most 100 characters without HTML.
For a table of the diagnostics, diagnostics/summarize groups them by
kind, and validate/location writes the path of each as people read it:
(validate/location [:rss/items 6 :rss/enclosure :url])
;; => "item 7 › enclosure › url"
To validate a feed that you've built rather than fetched, e.g. before you
publish it, give the map to validate/validate. The next section checks
the files that a feed links to, and adding the :report of
check/check-files! to the diagnostics gives a verdict on both.
The value blocks have checks of their own. Of these,
recipients/problems reports what would send a payment where the
podcaster doesn't mean it to go, e.g. fees of more than 100 percent or an
address of the wrong form, and recipients/check! asks each Lightning
address for its terms and its node for keysend.
To check a feed before you publish it, or one that a podcaster submits,
use check/check-files!. It fetches the media files, images, transcripts
and chapters files of the newest items, and tells whether they reach a
listener as the feed says. Give it the feed's URL to check the feed itself
too, and the requirements of PSP-1, Apple Podcasts, Spotify or YouTube as
:requirements:
(def result
(check/check-files! feed {:url url :max-items 10 :requirements :apple}))
(diagnostics/summarize (:report result))
;; => ({:id :check/cors :level :warning :count 10 …}
;; {:id :check/ranges :level :error :count 3 …} …)
(check/report (:files result) :psp-1) ; other requirements, no new requests
Without :read? true, it only sends a HEAD request and asks for the first
bytes of each file. With it, it also reads the media files and the HLS
playlists of video, and compares their duration, codecs and height with
what the feed declares. The limits on files, time and bytes are in
check/default-limits. Like fetch!, it requests nothing on your own
network unless you give it an :allow-pred. For a single file,
http/probe! returns the same facts, e.g. the size of an episode before
downloading it.
As a host, you count the downloads of your episodes from your server logs,
by the rules of the IAB. Read the lines of a log in the combined format of
Apache and nginx, or the extended format of the W3C, with logs/requests:
(def requests
(with-open [r (io/reader "access.log")]
(doall (logs/requests (line-seq r)
{:base-url "https://cdn.example.com/"}))))
The :base-url turns the paths in the log into the URLs of the files. A
line that can't be parsed becomes a map with an :error. If nginx writes
X-Forwarded-For after the user agent, give :extra [:forwarded-for]. For
a log in another format, turn each line into a request map yourself:
{:time #inst "2026-09-26T08:15:02Z"
:ip "203.0.113.5"
:user-agent "Overcast/3.0 (+http://overcast.fm/; iOS podcast app)"
:method :get
:url "https://cdn.example.com/episode-54.mp3"
:status 206
:range "bytes=0-"
:bytes 1048576}
Then give analytics/downloads the requests, the open list of user agents
from https://github.com/opawg/user-agents-v2, and the bytes that each
file needs to count, which analytics/threshold works out:
(defn read-user-agents
"The OPAWG list of user agents in the checkout at `dir`."
[dir]
(analytics/user-agents
(into {} (for [k [:bots :apps :libraries :browsers :devices :referrers]]
[k (slurp (str dir "/src/" (name k) ".json"))]))))
(def threshold
(analytics/threshold {:size 43200000 :header 1253 :duration 2700}))
(def result
(analytics/downloads
requests
{:user-agents (read-user-agents "user-agents-v2")
:threshold {episode-url threshold}
:exclude-pred data-center?}))
(frequencies (map :url (:downloads result))) ; downloads per file
(count (distinct (map :listener (:downloads result)))) ; listeners
(frequencies (map :agent-name (:downloads result))) ; apps
A reading of the file gives the numbers of the threshold: its :bytes
are the size, and its :media-start, where the audio starts after the
ID3 tag, is the header:
(let [{:keys [bytes media-start duration]} (media/read! episode-url)]
(analytics/threshold {:size bytes :header media-start :duration duration}))
The requests that didn't count are under :dropped, by reason. Here
data-center? is your own predicate, built from lists of data centres,
VPNs and bots, as the IAB asks. A day is a calendar day in UTC, and
:window :rolling or :extending counts the 24 hours from a listener's
first or last request instead.
OP3 counts downloads when a feed puts its prefix in front of each
enclosure, which op3/with-prefix does:
(rss/emit (op3/with-prefix feed))
;; <enclosure url="https://op3.dev/e,pg=…/cdn.example.com/episode-54.mp3" …/>
Its API needs the token of an API key from https://op3.dev/api/keys:
(def show (op3/call! token :show {:show (:podcast/guid feed) :episodes? true}))
(op3/call! token :episode-download-counts {:show (:show-uuid show)})
;; => {:episodes [{:item-guid "PC20-272" :downloads-all 1168 …} …] …}
(def month
(op3/fetch-downloads! token {:show (:show-uuid show)
:start "2026-09"
:end "2026-10"}))
A download from OP3 has the same keys as one from analytics/downloads,
and its :url is that of the file, without OP3's prefix, as op3/target
gives it, so the same code counts both. To try the API first, see the
sample token in Services. If a page fails,
fetch-downloads! returns the pages before it with an :error, and a
:continuation-token to read on from.
This section is for hosts. A feed that you build from your own data lacks
a few things that apps rely on, so fill them in with
publish/with-defaults, e.g. the self link, the podcast:guid, and a guid
and a media type for each episode. Serve it with publish/response:
(def feed
(publish/with-defaults show {:url "https://example.org/feed.xml"}))
(defn feed-handler
"The Ring response to the `request` for the feed."
[request]
(publish/response request feed))
The response has an ETag, a Last-Modified and a Cache-Control, and it's a
304 when the app has the feed already, so a check of an unchanged feed
costs next to nothing. It names the feed's WebSub hubs in a Link header
too. With :media-fn, a lookup of what media/read! gave for each file,
with-defaults fills in the length and the duration of the episodes.
A long feed is slow to fetch, so split it with publish/pages into
documents linked as RFC 5005 describes:
(def documents
(publish/pages feed {:url "https://example.org/feed.xml"
:kind :archived
:page-size 100}))
(map :url documents)
;; => ("https://example.org/feed.xml" ; the newest items
;; "https://example.org/feed.xml?archive=2" ; archives, newest first
;; "https://example.org/feed.xml?archive=1")
The archives don't change when a new episode comes out, so apps fetch each
one only once. Give the feed's URL as :url when the feed doesn't name
its own, and other URLs for the archives as :page-url-fn, a function of
the page number.
To push changes through WebSub, name the hub in the feed and tell it about each change:
(def feed
(update feed :atom/links conj {:rel "hub" :href "https://hub.example.org/"}))
(websub/publish! ["https://hub.example.org/"] "https://example.org/feed.xml")
To announce a change through Podping, give podping/notify! the token
that podping.cloud gave you. It returns the :status of the answer, or an
:error when the service didn't take it:
(podping/notify! token "https://example.org/feed.xml")
;; => {:status 200}
A private feed has a token for each subscriber. You make and check the
tokens on your server, and publish/for-subscriber writes one into the URLs
of the feed. The result is also closed to the directories:
(def private (publish/for-subscriber feed "k3J9x2"))
(get-in private [:rss/items 0 :rss/enclosure :url])
;; => "https://media.example.org/54.mp3?token=k3J9x2"
Apply op3/with-prefix after for-subscriber, since the prefix goes in
front of the whole URL.
To import a show from another host, copy each file that check/files
lists, and point the feed at the copies with publish/imported:
(def copies
(into {}
(for [{:keys [url]} (check/files feed {:max-items :all})]
[url (copy-file! url)])))
(def imported
(-> (publish/imported feed #(get copies % %))
(publish/with-defaults {:url "https://example.org/feed.xml"})
(publish/moved "https://example.org/feed.xml")))
Here copy-file! is your own function, which copies a file to your server
and returns its new URL. The episodes and the show keep their guids, so
the apps don't list them twice, and the links of the old host go. A feed
that resolve/locked? says is locked throws, since its owner has to
unlock it at the old host first. Then have the old host answer the old
URL with a 301 redirect to yours, and keep the redirect and the
itunes:new-feed-url of moved for at least four weeks, as Apple asks.
To move a show away from a host that can't redirect, serve the old feed
with moved instead.
An MP3 or M4A file can hold its chapters with an image for each, and
media/read! gives the images as data URLs. JSON Chapters says that an
image is a URL, so write the images as files before you serve the
chapters file:
(defn image-url
"The URL of the file that the image at `url` is written to, if it's a
data URL, or else `url`."
[url]
(if-let [{:keys [type data]} (image/from-data-url url)]
(write-image! type data)
url))
(def file (media/read! "https://media.example.org/54.mp3"))
(chapters/emit-json (chapters/update-image-urls (:chapters file) image-url))
Here write-image! is your own function too, which writes an image of a
MIME type and returns the URL of the file.
What podcast-clj knows about a service is a default that an option replaces, and Services and their defaults lists them all. To use a copy of a directory, start from the entry of one with the same API:
(def mirror
(assoc (:fyyd directory/sources)
:id :mirror
:root "https://fyyd.example.org/0.2/"))
(directory/search! "podcasting 2.0" {:sources [:apple mirror]})
For another instance of a service, give its address:
(podcastindex/call! credentials :podcasts/by-guid {:guid guid}
{:base-url "https://index.example.org/api/1.0/"})
(podping/notify-request token feed-url {:gateway "https://ping.example.org/"})
(op3/with-prefix feed {:origin "https://op3.example.org"})
A gpodder server with other paths takes them as :api, and a directory
that changed its requirements takes them as :requirements, e.g.
(assoc-in (:apple check.rules/requirements) [:artwork :sizes] [1400 4000]).
A test of your app can run a library with no network and no clock. The
sender of testing/sender answers each request from a map of URLs to
responses, with a 304 when the request names the ETag of its response,
and testing/feed writes the XML of a feed with as many episodes as you
ask for. Give the sender as the :send of the config, and a clock that
stands still as its :now-fn:
(def url "https://example.com/feed.xml")
(def responses
(atom {url {:body (testing/feed {:episodes 3})
:headers {"ETag" "\"v1\""}}}))
(defn send
"Answer a request from the responses as they are now."
[request]
((testing/sender @responses) request))
(def lib
(atom (library/make "phone" {:config {:send send
:now-fn (constantly #inst "2026-02-01")}})))
(swap! lib library/subscribe url (podcast/fetch! url {:send send})
{:auto-queue? true})
;; a new episode comes out
(swap! responses assoc url {:body (testing/feed {:episodes 4})})
(swap! lib library/merge (library/refresh! @lib {:at #inst "2026-02-02"}))
(profile.queue/queue (:profile @lib))
;; => [[feed-guid "https://example.com/4"]]
For sync, a backend over store/memory keeps the documents in an atom,
and two libraries of different devices sync through one such store.
downloads/plan takes the files of a device as data, so a test of the
downloads needs no files either.
The namespaces outside dk.simongray.podcast are those of the libraries
that Before you start lists, those of play-nice,
which Play episodes adds, and those of httpsig-clj, which
Show the comments on an episode adds.
(ns my.app
(:require [clojure.java.io :as io]
[dk.simongray.drop-in-search :as search]
[dk.simongray.drop-in-search.ciff :as ciff]
[dk.simongray.drop-in-search.queries :as search.queries]
[dk.simongray.html-pieces :as html]
[dk.simongray.httpsig :as httpsig]
[dk.simongray.httpsig.cavage :as cavage]
[dk.simongray.like-minded.store :as store]
[dk.simongray.like-minded.store.encrypted :as encrypted]
[dk.simongray.like-minded.store.folder :as folder]
[dk.simongray.like-minded.store.indexeddb :as indexeddb]
[dk.simongray.like-minded.store.webdav :as webdav]
[dk.simongray.like-minded.store.webdav.server :as webdav-server]
[dk.simongray.mind-meld :as crdt]
[dk.simongray.play-nice.capabilities :as capabilities]
[dk.simongray.play-nice.session :as session]
[dk.simongray.podcast :as podcast]
[dk.simongray.podcast.analytics :as analytics]
[dk.simongray.podcast.analytics.logs :as logs]
[dk.simongray.podcast.analytics.op3 :as op3]
[dk.simongray.podcast.app.episode :as episode]
[dk.simongray.podcast.app.feeds :as feeds]
[dk.simongray.podcast.app.library :as library]
[dk.simongray.podcast.app.media-session :as media-session]
[dk.simongray.podcast.app.show :as show]
[dk.simongray.podcast.comments :as comments]
[dk.simongray.podcast.comments.atproto :as atproto]
[dk.simongray.podcast.comments.nostr :as nostr-comments]
[dk.simongray.podcast.criteria :as criteria]
[dk.simongray.podcast.diagnostics :as diagnostics]
[dk.simongray.podcast.directory :as directory]
[dk.simongray.podcast.directory.links :as links]
[dk.simongray.podcast.directory.podcastindex :as podcastindex]
[dk.simongray.podcast.downloads :as downloads]
[dk.simongray.podcast.feed.check :as check]
[dk.simongray.podcast.feed.check.rules :as check.rules]
[dk.simongray.podcast.feed.discovery :as discovery]
[dk.simongray.podcast.feed.guid :as feed.guid]
[dk.simongray.podcast.feed.local :as local]
[dk.simongray.podcast.feed.publish :as publish]
[dk.simongray.podcast.feed.resolve :as resolve]
[dk.simongray.podcast.feed.schedule :as schedule]
[dk.simongray.podcast.feed.repair :as repair]
[dk.simongray.podcast.feed.rss :as rss]
[dk.simongray.podcast.feed.search :as feed.search]
[dk.simongray.podcast.feed.validate :as validate]
[dk.simongray.podcast.format.chapters :as chapters]
[dk.simongray.podcast.format.image :as image]
[dk.simongray.podcast.format.media :as media]
[dk.simongray.podcast.format.transcript :as transcript]
[dk.simongray.podcast.gpodder :as gpodder]
[dk.simongray.podcast.nostr.signer :as signer]
[dk.simongray.podcast.podping :as podping]
[dk.simongray.podcast.profile :as profile]
[dk.simongray.podcast.profile.marks :as profile.marks]
[dk.simongray.podcast.profile.opml :as profile.opml]
[dk.simongray.podcast.profile.payments :as profile.payments]
[dk.simongray.podcast.profile.queue :as profile.queue]
[dk.simongray.podcast.testing :as testing]
[dk.simongray.podcast.util.bytes.ranges :as ranges]
[dk.simongray.podcast.util.time :as time]
[dk.simongray.podcast.value :as value]
[dk.simongray.podcast.value.bolt11 :as bolt11]
[dk.simongray.podcast.value.lnaddress :as lnaddress]
[dk.simongray.podcast.value.metadata :as metadata]
[dk.simongray.podcast.value.nwc :as nwc]
[dk.simongray.podcast.value.recipients :as recipients]
[dk.simongray.podcast.value.webln :as webln]
[dk.simongray.wary-fetch :as http]
[dk.simongray.wary-fetch.proxy :as proxy]
[dk.simongray.wary-fetch.secret :as secret]
[dk.simongray.wary-fetch.url :as url]
[dk.simongray.websub :as websub]))
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 |