Fetching a podcast feed or an OPML list as data, in one call.
Fetching a podcast feed or an OPML list as data, in one call.
The downloads of episodes, counted by the rules of the IAB Tech Lab from the requests that a podcast host logs.
The rules are those of the Podcast Measurement Technical Guidelines, version 2.3 of September 2026. They count the episodes of video too, when a feed delivers them as files.
A request is a map of one line of the log of a server:
{:time #inst "2026-09-26T08:15:02Z"
:ip "203.0.113.5" ; or a hash of it
: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-" ; the Range header
:bytes 1048576 ; of the body that was sent
:referer nil}
The requests of a listener for a file in 24 hours count as one download when they hold enough of the file to play for one minute, and a listener is an IP address and a user agent. The requests that can't be part of a download are left out, such as HEAD requests and the requests of bots.
The rules need data from outside podcast-clj: the open list of user agents of the OPAWG, which names the apps and the bots, and lists of the addresses of data centers, VPNs and known bots from other sources.
The downloads of episodes, counted by the rules of the IAB Tech Lab from
the requests that a podcast host logs.
The rules are those of the Podcast Measurement Technical Guidelines,
version 2.3 of September 2026. They count the episodes of video too,
when a feed delivers them as files.
A request is a map of one line of the log of a server:
{:time #inst "2026-09-26T08:15:02Z"
:ip "203.0.113.5" ; or a hash of it
: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-" ; the Range header
:bytes 1048576 ; of the body that was sent
:referer nil}
The requests of a listener for a file in 24 hours count as one download
when they hold enough of the file to play for one minute, and a
listener is an IP address and a user agent. The requests that can't be
part of a download are left out, such as HEAD requests and the requests
of bots.
The rules need data from outside podcast-clj: the open list of user
agents of the OPAWG, which names the apps and the bots, and lists of the
addresses of data centers, VPNs and known bots from other sources.The requests of a server log as the request maps that dk.simongray.podcast.analytics counts downloads from.
It reads two formats:
The requests come one line at a time, so a log of any size can be read from line-seq. A line that doesn't read gives a map with a diagnostic under :error, which analytics/downloads leaves out as malformed.
The requests of a server log as the request maps that dk.simongray.podcast.analytics counts downloads from. It reads two formats: - the combined format of Apache and nginx, and the common format, which lacks the referer and the user agent of the combined one - the extended format of the W3C, whose #Fields line names its fields, e.g. the logs of IIS and of some CDNs The requests come one line at a time, so a log of any size can be read from line-seq. A line that doesn't read gives a map with a diagnostic under :error, which analytics/downloads leaves out as malformed.
OP3, the Open Podcast Prefix Project: a free service that counts the downloads of episodes through a prefix of their URLs, and its API.
A host puts the prefix of OP3 in front of the URL of each enclosure. OP3 then counts each request, and redirects it to the file.
The API gives the downloads of a show, the counts of the show and of its episodes, and the apps of the listeners. Each call needs the token of an API key from https://op3.dev/api/keys.
A download of OP3 has the keys of the downloads of dk.simongray.podcast.analytics where OP3 tells the same fact. They're the :time, the :url, the :listener, the agent, and the :ip, which OP3 keeps as a hash. OP3 counts its downloads by its own rules, which are close to those of the IAB.
OP3, the Open Podcast Prefix Project: a free service that counts the downloads of episodes through a prefix of their URLs, and its API. A host puts the prefix of OP3 in front of the URL of each enclosure. OP3 then counts each request, and redirects it to the file. The API gives the downloads of a show, the counts of the show and of its episodes, and the apps of the listeners. Each call needs the token of an API key from https://op3.dev/api/keys. A download of OP3 has the keys of the downloads of dk.simongray.podcast.analytics where OP3 tells the same fact. They're the :time, the :url, the :listener, the agent, and the :ip, which OP3 keeps as a hash. OP3 counts its downloads by its own rules, which are close to those of the IAB.
Everything an episode page needs, in one map.
Everything an episode page needs, in one map.
The fetched feeds of an app, and keeping them current.
The feeds are a map by key, the guid that the feed was first fetched under. Each entry has the parsed feed, its URL, the ETag and Last-Modified of the last fetch, the guid that the feed gives itself now, when the feed last changed, and the time of the next check:
{"917393e3-…" {:url "https://feeds.podcastindex.org/pc20.xml"
:feed {…}
:retained {item-guid {:missed-at #inst "…"
:gone? true}}
:etag "…"
:last-modified "…"
:guid "917393e3-…" ; what the feed says now
:changed-at #inst "…"
:fetched-at #inst "…"
:next-check #inst "…"
:failures 0
:status 200}}
The feed keeps the items that a fetch no longer brings back, after the fresh ones, since many hosts serve only the newest items. Each has a fact under :retained: when a fetch first missed it, and :gone? when the host took it out rather than leaving it to age out of a capped feed, as it's later than the oldest fresh item, or has no date.
An entry with only its :url is a feed to fetch for the first time, e.g. of a new subscription, and it's due at once. An entry that's :gone? is never due, as default-cadence says, and nor is one that's :local?, the feed of a show of local files, which has no URL.
The fetched feeds of an app, and keeping them current.
The feeds are a map by key, the guid that the feed was first fetched
under. Each entry has the parsed feed, its URL, the ETag and
Last-Modified of the last fetch, the guid that the feed gives itself
now, when the feed last changed, and the time of the next check:
{"917393e3-…" {:url "https://feeds.podcastindex.org/pc20.xml"
:feed {…}
:retained {item-guid {:missed-at #inst "…"
:gone? true}}
:etag "…"
:last-modified "…"
:guid "917393e3-…" ; what the feed says now
:changed-at #inst "…"
:fetched-at #inst "…"
:next-check #inst "…"
:failures 0
:status 200}}
The feed keeps the items that a fetch no longer brings back, after the
fresh ones, since many hosts serve only the newest items. Each has a fact
under :retained: when a fetch first missed it, and :gone? when the host
took it out rather than leaving it to age out of a capped feed, as it's
later than the oldest fresh item, or has no date.
An entry with only its :url is a feed to fetch for the first time, e.g.
of a new subscription, and it's due at once. An entry that's :gone? is
never due, as default-cadence says, and nor is one that's :local?, the
feed of a show of local files, which has no URL.The subscriptions, feeds, queue and play progress of an app, in one map.
The parts of a library are:
{:device "…" ; the id of this device
:profile {…} ; the profile, a CRDT that syncs
:feeds {feed-guid {…}} ; the feeds, as app.feeds keeps them
:fetched-lists {url {…}} ; the followed OPML lists, as fetched
:index {…} ; the search index, with :search?
:sync {:cursors {…} …} ; the last sync's cursors and report
:refresh {:added […] …} ; the new episodes, moves and report
:effects [[:seek id 784]] ; the effects of the last event, to apply
:sleep {:episodes 1} ; the sleep timer of this device, if set
:files {id {…}} ; the downloaded files of this device
:config {…}} ; the options below
The profile is a CRDT, so never change it with assoc or dissoc. A feed keeps the episodes that a fetch no longer brings back, as app.feeds says, and the views and listings mark each with its :retained fact. With :max-items, the oldest of them go once a feed has more items, but never one that the profile holds anything for, e.g. progress, a played mark that isn't archived, a star or a place in the queue. The config holds the options that the functions take, given once:
{:fetch f ; in place of podcast/fetch!
:send f ; in place of client/send!
:allow-pred url/public? ; which URLs a request may go to
:credentials-of f ; credentials of a private feed by URL
:index {…} ; the credentials of the Podcast Index
:languages ["da" "en"] ; of the listener, for transcripts
:search? true ; whether to keep the search index
:cadence {…} ; as app.feeds takes it
:concurrency 8 ; how many feeds are fetched at once
:max-pages 5 ; of a paged feed, at its first fetch
:max-items 500 ; the most items of a feed, all by default
:archive-after 31536000000 ; when played episodes are archived, in ms
:only-newer? false ; older episodes that appear are new too
:keep-fn app.feeds/slim ; what to keep of a feed, all by default
:local-file-fn f ; the downloaded file of an episode id
:cache store ; for companion files, see read-files!
:now-fn f ; the clock, util.time/now by default
:timeout-seconds 15 :max-bytes … :user-agent …}
Each function also takes the same keys as options, for one call, and :at, an instant to stamp each change with in place of the time of the clock.
The subscriptions, feeds, queue and play progress of an app, in one map.
The parts of a library are:
{:device "…" ; the id of this device
:profile {…} ; the profile, a CRDT that syncs
:feeds {feed-guid {…}} ; the feeds, as app.feeds keeps them
:fetched-lists {url {…}} ; the followed OPML lists, as fetched
:index {…} ; the search index, with :search?
:sync {:cursors {…} …} ; the last sync's cursors and report
:refresh {:added […] …} ; the new episodes, moves and report
:effects [[:seek id 784]] ; the effects of the last event, to apply
:sleep {:episodes 1} ; the sleep timer of this device, if set
:files {id {…}} ; the downloaded files of this device
:config {…}} ; the options below
The profile is a CRDT, so never change it with assoc or dissoc. A feed
keeps the episodes that a fetch no longer brings back, as app.feeds
says, and the views and listings mark each with its :retained fact.
With :max-items, the oldest of them go once a feed has more items, but
never one that the profile holds anything for, e.g. progress, a played
mark that isn't archived, a star or a place in the queue. The config
holds the options that the functions take, given once:
{:fetch f ; in place of podcast/fetch!
:send f ; in place of client/send!
:allow-pred url/public? ; which URLs a request may go to
:credentials-of f ; credentials of a private feed by URL
:index {…} ; the credentials of the Podcast Index
:languages ["da" "en"] ; of the listener, for transcripts
:search? true ; whether to keep the search index
:cadence {…} ; as app.feeds takes it
:concurrency 8 ; how many feeds are fetched at once
:max-pages 5 ; of a paged feed, at its first fetch
:max-items 500 ; the most items of a feed, all by default
:archive-after 31536000000 ; when played episodes are archived, in ms
:only-newer? false ; older episodes that appear are new too
:keep-fn app.feeds/slim ; what to keep of a feed, all by default
:local-file-fn f ; the downloaded file of an episode id
:cache store ; for companion files, see read-files!
:now-fn f ; the clock, util.time/now by default
:timeout-seconds 15 :max-bytes … :user-agent …}
Each function also takes the same keys as options, for one call, and
:at, an instant to stamp each change with in place of the time of the
clock.The Media Session of a browser for the episode that plays: what the lock screen and the notification show, and what their buttons and those of the headphones do, as player events for library/step.
The functions are pure. With the session namespace of the play-nice
library, a player of the episode id in the media element audio wires
them up like this, with a step! that runs an event through library/step
and does its effects:
(session/metadata! (:metadata (library/playback @lib id)))
(session/actions!
(zipmap (media-session/actions @lib id)
(repeat (fn [details]
(run! step! (media-session/events
@lib id (.-currentTime audio)
details))))))
The next and previous buttons go where the listener's preference :track-buttons says, as profile/default-preferences has it.
The Media Session of a browser for the episode that plays: what the lock
screen and the notification show, and what their buttons and those of
the headphones do, as player events for library/step.
The functions are pure. With the session namespace of the play-nice
library, a player of the episode `id` in the media element `audio` wires
them up like this, with a step! that runs an event through library/step
and does its effects:
(session/metadata! (:metadata (library/playback @lib id)))
(session/actions!
(zipmap (media-session/actions @lib id)
(repeat (fn [details]
(run! step! (media-session/events
@lib id (.-currentTime audio)
details))))))
The next and previous buttons go where the listener's preference
:track-buttons says, as profile/default-preferences has it.Everything a show page needs, in one map.
Everything a show page needs, in one map.
The public comments on an episode or a show, from the thread below the root post that podcast:socialInteract names.
The comments of these protocols come in one shape:
A thread is a map. An unread thread is a map of the :protocol and the :uri of a podcast:socialInteract, and a thread that was read also has:
A comment:
{:id "https://example.social/users/ann/statuses/2"
:url "https://example.social/@ann/2" ; its web page
:author "https://example.social/users/ann" ; in :authors
:in-reply-to "https://example.social/users/bob/statuses/1"
:published #inst "2026-09-19T00:22:52Z"
:updated #inst "2026-09-19T00:30:00Z"
:title "…" ; of a video or an article
:content "<p>…</p>" ; sanitized HTML with absolute links
:language "en"
:summary "…" ; a content warning
:sensitive? true ; to show behind a warning
:labels #{"graphic-media"} ; what makes it sensitive
:attachments [{:kind :image :url "…" :preview "…" :alt "…"
:media-type "image/png" :width 1200 :height 800
:aspect-ratio [1200 800]}]
:poll {:choices [{:name "Yes" :votes 3}] :multiple? true
:ends #inst "2026-09-20T00:00:00Z"}
:counts {:replies 2 :reposts 1 :likes 5 :quotes 0}
:hidden? true ; hidden by the author of the root post
:read-at #inst "2026-09-28T12:00:00Z"}
A deleted post is :deleted?, and a post that podcast-clj didn't get has an :error that tells why. The :counts are those that the server of a comment gives with it. On Nostr, they're the :likes and :reposts that the relays gave, one for each author. An author is a map of :id, :name, :handle, :url and :avatar. The handle is @name@domain on ActivityPub, @handle on the AT Protocol and npub1… on Nostr. A thread that podcast-clj read through the Mastodon API has :via :mastodon-api. The keys in this namespace, such as ::collection, record how far the reading went.
The public comments on an episode or a show, from the thread below the
root post that podcast:socialInteract names.
The comments of these protocols come in one shape:
- ActivityPub, which Mastodon, PeerTube and the podcast host Castopod
speak
- the AT Protocol, which Bluesky speaks, from an AppView
- Nostr, from relays, which also has the comments about an episode that
names no root post
A thread is a map. An unread thread is a map of the :protocol and the
:uri of a podcast:socialInteract, and a thread that was read also has:
- the id of the :root post
- the :comments by id
- their :authors by id
- the :report of what went wrong
A comment:
{:id "https://example.social/users/ann/statuses/2"
:url "https://example.social/@ann/2" ; its web page
:author "https://example.social/users/ann" ; in :authors
:in-reply-to "https://example.social/users/bob/statuses/1"
:published #inst "2026-09-19T00:22:52Z"
:updated #inst "2026-09-19T00:30:00Z"
:title "…" ; of a video or an article
:content "<p>…</p>" ; sanitized HTML with absolute links
:language "en"
:summary "…" ; a content warning
:sensitive? true ; to show behind a warning
:labels #{"graphic-media"} ; what makes it sensitive
:attachments [{:kind :image :url "…" :preview "…" :alt "…"
:media-type "image/png" :width 1200 :height 800
:aspect-ratio [1200 800]}]
:poll {:choices [{:name "Yes" :votes 3}] :multiple? true
:ends #inst "2026-09-20T00:00:00Z"}
:counts {:replies 2 :reposts 1 :likes 5 :quotes 0}
:hidden? true ; hidden by the author of the root post
:read-at #inst "2026-09-28T12:00:00Z"}
A deleted post is :deleted?, and a post that podcast-clj didn't get has
an :error that tells why. The :counts are those that the server of a
comment gives with it. On Nostr, they're the :likes and :reposts that
the relays gave, one for each author. An author is a map of :id, :name,
:handle, :url and :avatar. The handle is @name@domain on ActivityPub,
@handle on the AT Protocol and npub1… on Nostr. A thread that
podcast-clj read through the Mastodon API has :via :mastodon-api. The
keys in this namespace, such as ::collection, record how far the
reading went.Threads of comments over ActivityPub, the protocol of Mastodon, PeerTube and the podcast host Castopod.
Each post is a JSON document at its own URL. A root post can start a conversation and name a collection of all its posts. This collection is the context of FEP-f228, or the comments of a PeerTube video. A reading asks for that collection, or else for the replies collection of each post, down the tree. It asks for each post that a collection names by URL at the post's own server, and for each author too. A server speaks only for its own posts and authors, as FEP-fe34 asks, and a post that isn't public is left out.
Some servers answer only signed requests, and Mastodon lets no page on another site read its posts. For such a root post, the reading asks the Mastodon API of its server for the thread. Many servers answer this API, which speaks only for its own server too.
Threads of comments over ActivityPub, the protocol of Mastodon, PeerTube and the podcast host Castopod. Each post is a JSON document at its own URL. A root post can start a conversation and name a collection of all its posts. This collection is the context of FEP-f228, or the comments of a PeerTube video. A reading asks for that collection, or else for the replies collection of each post, down the tree. It asks for each post that a collection names by URL at the post's own server, and for each author too. A server speaks only for its own posts and authors, as FEP-fe34 asks, and a post that isn't public is left out. Some servers answer only signed requests, and Mastodon lets no page on another site read its posts. For such a root post, the reading asks the Mastodon API of its server for the thread. Many servers answer this API, which speaks only for its own server too.
Threads of comments on the AT Protocol, the protocol of Bluesky, read from an AppView.
An AppView indexes the network. It gives the thread below a post in one call, app.bsky.feed.getPostThread, without an account. The labels that the protocol defines apply:
A thread starts at the at URI of a post, or at the URL of its page on Bluesky's web app. Each comment and each author has the :url of its page there, and post-url and profile-url give the page on another web app.
Threads of comments on the AT Protocol, the protocol of Bluesky, read from an AppView. An AppView indexes the network. It gives the thread below a post in one call, app.bsky.feed.getPostThread, without an account. The labels that the protocol defines apply: - a reply whose author asks apps to show it only to signed-in users is left out - a reply that a moderation service hid is left out - a reply with a warning is :sensitive? - a reply that the author of the root post hid is :hidden? A thread starts at the at URI of a post, or at the URL of its page on Bluesky's web app. Each comment and each author has the :url of its page there, and post-url and profile-url give the page on another web app.
What the readers of the comments of ActivityPub and of the AT Protocol share:
What the readers of the comments of ActivityPub and of the AT Protocol share: - the values in JSON answers, and the problem with an answer - the HTML of a post, made safe to show - the posts that a thread withholds - the order of the requests by the depth of their posts
No vars found in this namespace.
Threads of comments on Nostr, read from relays.
A thread has one of two kinds of root:
No post is the root of the thread of an episode or a show, so its root is a comment without content.
The comments are the text notes of kind 1, with the replies of NIP-10, and the comments of kind 1111 of NIP-22. The authors are the events of kind 0 of NIP-01, and an event of kind 5 of NIP-09 deletes a comment of its author.
A relay can send anything. So a reading keeps only the events whose signatures verify and that pass a filter of the request. A thread counts the events that it left out, and the report tells the count.
The relays are those that the root names, and then the :relays of the options, or the default ones. Each relay gets the same questions, and an event that two relays give is one comment.
Threads of comments on Nostr, read from relays. A thread has one of two kinds of root: - a note, which podcast:socialInteract names by a nostr: URI of NIP-21 or by a name of NIP-19, such as nevent1… or note1… - the episode or the show itself, by the id that NIP-73 gives it, such as podcast:item:guid:… No post is the root of the thread of an episode or a show, so its root is a comment without content. The comments are the text notes of kind 1, with the replies of NIP-10, and the comments of kind 1111 of NIP-22. The authors are the events of kind 0 of NIP-01, and an event of kind 5 of NIP-09 deletes a comment of its author. A relay can send anything. So a reading keeps only the events whose signatures verify and that pass a filter of the request. A thread counts the events that it left out, and the report tells the count. The relays are those that the root names, and then the :relays of the options, or the default ones. Each relay gets the same questions, and an event that two relays give is one comment.
Criteria that select and order episodes, given as data, e.g. for a saved filter or a download rule.
Criteria that select and order episodes, given as data, e.g. for a saved filter or a download rule.
Every diagnostic that the library can put in a report, by id, with its level and what it means.
The ids are API, since apps match on them, and doc/diagnostics.md lists them all. The namespace of an id names what raises it:
The level says how much a diagnostic matters:
The requirements of a directory can raise a level, e.g. a repeated enclosure URL is an error for Apple. An error under Apple's requirements breaks a rule of its guide, though Apple Podcasts Connect doesn't say which rules it refuses a feed for.
Every diagnostic that the library can put in a report, by id, with its level and what it means. The ids are API, since apps match on them, and doc/diagnostics.md lists them all. The namespace of an id names what raises it: - fetch, the fetch of a feed - discovery, finding the feeds of a show from an address - directory, a search or a chart of the directories - repair, the text repairs of a malformed document - xml, the resolution of XML namespaces - quirk, the normalizations of the tree - feed, the model - transcript and chapters, the companion formats - mp3, mp4 and file, media files and images - id3, the ID3v2 tags of media files, whose diagnostics are those of id3-clj - validate, the validator, whose diagnostics are about the feed rather than about reading it - check, the checks over the network of what the feed links to - sync, a sync step, whose diagnostics are those of like-minded - gpodder, a sync through the gpodder API - library, a library saved to a store and read back - podping, a poll of the Hive chain - comments, reading a thread of comments - value, the records of payments - analytics, the list of user agents and the lines of server logs The level says how much a diagnostic matters: - :error, a rule of a spec or of the chosen requirements is broken, so apps or a directory can misread the feed, or refuse it - :warning, the feed works, but apps are likely to show or treat something worse than the podcaster means - :info, something worth knowing that needs no change, e.g. a repair that the parse made The requirements of a directory can raise a level, e.g. a repeated enclosure URL is an error for Apple. An error under Apple's requirements breaks a rule of its guide, though Apple Podcasts Connect doesn't say which rules it refuses a feed for.
Apple Podcasts, fyyd, gpodder.net and the Podcast Index behind one shape. You can search them, look shows up, read their charts and list episodes, and every answer is keyed like a parsed feed.
A request is a map that any HTTP client can send, and a response reads into shows, which are maps with the keys of a parsed feed where the directory has the same information:
Apple, fyyd and gpodder.net need no credentials, and the Podcast Index takes its under the :credentials option.
Apple Podcasts, fyyd, gpodder.net and the Podcast Index behind one shape. You can search them, look shows up, read their charts and list episodes, and every answer is keyed like a parsed feed. A request is a map that any HTTP client can send, and a response reads into shows, which are maps with the keys of a parsed feed where the directory has the same information: - :rss/title, :itunes/author and :itunes/image - the URL of the feed as the :atom/links entry with rel self - :podcast/guid when the directory knows the one that the feed declares. A guid derived from the URL would be a guess, since a feed that moved keeps the guid of its first URL. - keys in the directory namespace for what only the directory knows, such as :directory/source, :directory/id and the record itself as :directory/raw Apple, fyyd and gpodder.net need no credentials, and the Podcast Index takes its under the :credentials option.
Links to a show and to an episode in the apps of listeners, and a link to a time in a media file.
An app has its own form of link, and no standard defines it. Most forms hold a key that the app didn't make: the URL of the feed, the podcast:guid, the id of Apple Podcasts or the id of the Podcast Index.
A link with the scheme of an app, such as podcast:// or pktc://, opens the app and subscribes. It works only where the app is. A link to the web page of an app works in each browser.
A link to a time follows the one standard here: the media fragment of the W3C, which starts a media file at a time.
Links to a show and to an episode in the apps of listeners, and a link to a time in a media file. An app has its own form of link, and no standard defines it. Most forms hold a key that the app didn't make: the URL of the feed, the podcast:guid, the id of Apple Podcasts or the id of the Podcast Index. A link with the scheme of an app, such as podcast:// or pktc://, opens the app and subscribes. It works only where the app is. A link to the web page of an app works in each browser. A link to a time follows the one standard here: the media fragment of the W3C, which starts a media file at a time.
The Podcast Index API, https://podcastindex-org.github.io/docs-api/, as request maps and model maps.
A request is a map of :method, :url and :headers that any HTTP client can send, with the authentication for the time it's given. A response reads into data shaped like the feed model, so a feed or an episode from the index has the same keys as one parsed from RSS, and what only the index knows is in the index namespace.
The key and the secret never leave your process. Nothing here reads a clock but the functions that send.
The Podcast Index API, https://podcastindex-org.github.io/docs-api/, as request maps and model maps. A request is a map of :method, :url and :headers that any HTTP client can send, with the authentication for the time it's given. A response reads into data shaped like the feed model, so a feed or an episode from the index has the same keys as one parsed from RSS, and what only the index knows is in the index namespace. The key and the secret never leave your process. Nothing here reads a clock but the functions that send.
The episodes to download and the files to delete on a device, by the listener's rules.
Downloading, deleting and choosing when are up to you. The rules are the kinds that podcast apps have, and a rule selects its episodes with the options of criteria/episodes, e.g.:
{:download? true
:episodes {:where {:duration [600 nil] :not {:text #{"rerun"}}}
:limit 3}
:played 86400}
The rules sync in the profile, and a device can have rules of its own. The record of the files on a device, which the plan reads, is kept by start-download, finish-download, remove-file and keep-file, and app.library holds it and saves it.
The episodes to download and the files to delete on a device, by the
listener's rules.
Downloading, deleting and choosing when are up to you. The rules are the
kinds that podcast apps have, and a rule selects its episodes with the
options of criteria/episodes, e.g.:
{:download? true
:episodes {:where {:duration [600 nil] :not {:text #{"rerun"}}}
:limit 3}
:played 86400}
The rules sync in the profile, and a device can have rules of its own.
The record of the files on a device, which the plan reads, is kept by
start-download, finish-download, remove-file and keep-file, and
app.library holds it and saves it.The feed model: canonical Hiccup in, maps out, and back again, by a tag registry.
A feed is the channel map itself, with the items under :rss/items and the live items under :podcast/live-items. In the map:
Nothing throws on a bad value, which stays a string, and the report says so. The report is in the metadata of the result under :podcast/report.
The feed model: canonical Hiccup in, maps out, and back again, by a tag registry. A feed is the channel map itself, with the items under :rss/items and the live items under :podcast/live-items. In the map: - a scalar element becomes a coerced scalar - an element with attributes or children becomes a map - a repeatable element becomes a vector - an element that the registry doesn't know stays as Hiccup under :xml/unknown Nothing throws on a bad value, which stays a string, and the report says so. The report is in the metadata of the result under :podcast/report.
Checks over the network of what a feed links to: whether its media files, images, transcripts and chapters reach a listener as the feed says.
The checks follow the standards first: HTTP as RFC 9110 has it, the attributes of RSS 2.0 and of the podcast namespace, and the CORS rules of the Fetch standard. The CORS rules decide what a web player on another site can read. The :requirements option adds those of a directory, e.g. :apple for Apple Podcasts.
A check gathers the facts of each file over the network, and judges them by the rules of dk.simongray.podcast.feed.check.rules, which can judge the same facts again without a request.
In a browser, fetch hides most headers of an answer from another site, and it fails without a reason where CORS doesn't allow the page to read. So a check sees most on the JVM or in Node.
Checks over the network of what a feed links to: whether its media files, images, transcripts and chapters reach a listener as the feed says. The checks follow the standards first: HTTP as RFC 9110 has it, the attributes of RSS 2.0 and of the podcast namespace, and the CORS rules of the Fetch standard. The CORS rules decide what a web player on another site can read. The :requirements option adds those of a directory, e.g. :apple for Apple Podcasts. A check gathers the facts of each file over the network, and judges them by the rules of dk.simongray.podcast.feed.check.rules, which can judge the same facts again without a request. In a browser, fetch hides most headers of an answer from another site, and it fails without a reason where CORS doesn't allow the page to read. So a check sees most on the JVM or in Node.
The rules that judge the facts of a check: those of the standards, and the requirements of a directory.
The standards come first: HTTP as RFC 9110 has it, the attributes of RSS 2.0 and of the podcast namespace, and the CORS rules of the Fetch standard, which decide what a web player on another site can read. The rules are pure, so the facts of one check can be judged by several requirements, and no request goes out again.
The rules that judge the facts of a check: those of the standards, and the requirements of a directory. The standards come first: HTTP as RFC 9110 has it, the attributes of RSS 2.0 and of the podcast namespace, and the CORS rules of the Fetch standard, which decide what a web player on another site can read. The rules are pure, so the facts of one check can be judged by several requirements, and no request goes out again.
Coercion between the strings of a feed and the values of the model, by the value types of the tag registry.
Coercion between the strings of a feed and the values of the model, by the value types of the tag registry.
Finding the feeds of a show from what a listener types or pastes: the URL of a feed, a domain, the address of a web page or a link of an app.
The candidates are found as the feed finders of RSS readers find them, and ranked with the podcasts first, and then by where they were found. Each feed comes once, however many places name it.
Finding the feeds of a show from what a listener types or pastes: the URL of a feed, a domain, the address of a web page or a link of an app. The candidates are found as the feed finders of RSS readers find them, and ranked with the podcasts first, and then by where they were found. Each feed comes once, however many places name it.
The identity of feeds and episodes: a feed by its podcast:guid, or the one that the UUIDv5 scheme of podcast:guid derives from its URL, and an episode by its guid, on both platforms.
The identity of feeds and episodes: a feed by its podcast:guid, or the one that the UUIDv5 scheme of podcast:guid derives from its URL, and an episode by its guid, on both platforms.
The podcast:integrity of an alternate enclosure, and a check of a file against it.
The integrity is Subresource Integrity metadata of the W3C, e.g. sha384-…, or a PGP signature. Subresource Integrity is at https://www.w3.org/TR/sri/
The podcast:integrity of an alternate enclosure, and a check of a file against it. The integrity is Subresource Integrity metadata of the W3C, e.g. sha384-…, or a PGP signature. Subresource Integrity is at https://www.w3.org/TR/sri/
A show of local files that no feed serves, e.g. an audiobook, a folder of recordings or a podcast that someone sent, as a feed that app.library/subscribe-local takes.
The feed is built from what each file says about itself, as format.media/read! gives it, in the shape that feed.rss/parse gives. Its episodes go in the order of their file names, as those of a serial show do. The guid of the show comes from its title and author, and that of an episode from its file name, so two devices with the same files make the same show, and the profile syncs its progress between them.
A show of local files that no feed serves, e.g. an audiobook, a folder of recordings or a podcast that someone sent, as a feed that app.library/subscribe-local takes. The feed is built from what each file says about itself, as format.media/read! gives it, in the shape that feed.rss/parse gives. Its episodes go in the order of their file names, as those of a serial show do. The guid of the show comes from its title and author, and that of an episode from its file name, so two devices with the same files make the same show, and the profile syncs its progress between them.
What a host does to a feed: fill in what a producer leaves out, import it from another host or move it away, split a long feed into pages, make the private feed of a subscriber, rewrite the URLs of its files, and serve it.
What a host does to a feed: fill in what a producer leaves out, import it from another host or move it away, split a long feed into pages, make the private feed of a subscriber, rewrite the URLs of its files, and serve it.
Named fixes for feeds whose tree bends the standards: the namespace URIs that feeds misspell, and the normalizations of the elements, applied after the parse and always reported.
Every fix has an id, each use of one goes into a report, and the option :except leaves out those that it names. The repairs of a feed that doesn't parse are in dk.simongray.podcast.feed.repair.
Named fixes for feeds whose tree bends the standards: the namespace URIs that feeds misspell, and the normalizations of the elements, applied after the parse and always reported. Every fix has an id, each use of one goes into a report, and the option :except leaves out those that it names. The repairs of a feed that doesn't parse are in dk.simongray.podcast.feed.repair.
Named repairs for the text of a feed that isn't well-formed XML, applied only when the strict parse fails, and always reported.
Every repair has an id, each use of one goes into a report, and the option :except leaves out those that it names.
Named repairs for the text of a feed that isn't well-formed XML, applied only when the strict parse fails, and always reported. Every repair has an id, each use of one goes into a report, and the option :except leaves out those that it names.
One answer where the vocabularies give several: the accessors that a podcast app needs.
Each reads plain keys of a feed or an item. A form with two arguments takes the feed and an item, and the item overrides or inherits what the channel declares.
One answer where the vocabularies give several: the accessors that a podcast app needs. Each reads plain keys of a feed or an item. A form with two arguments takes the feed and an item, and the item overrides or inherits what the channel declares.
Podcast feeds as data, parsed leniently from RSS and written back in canonical form.
The feeds are RSS 2.0 with the iTunes namespace and the podcast namespace of Podcasting 2.0. The strict parse comes first, and the repairs only run when the document is malformed, with everything they did in the report.
Podcast feeds as data, parsed leniently from RSS and written back in canonical form. The feeds are RSS 2.0 with the iTunes namespace and the podcast namespace of Podcasting 2.0. The strict parse comes first, and the repairs only run when the document is malformed, with everything they did in the report.
When a feed says that it changes, and so when to look at it again.
The feed tells it by the rule of its podcast:updateFrequency, its ttl, its syndication period, or else the dates of its recent items, and a feed that says it's complete changes no more.
When a feed says that it changes, and so when to look at it again. The feed tells it by the rule of its podcast:updateFrequency, its ttl, its syndication period, or else the dates of its recent items, and a feed that says it's complete changes no more.
The feed model as Malli schemas, made from the tag registry so that they never drift from the model.
The schemas are plain data in the vector syntax of Malli, and Malli itself isn't a dependency. In your own project, give a schema to malli.core/schema, and validate, explain, generate or coerce with it:
(m/validate schema/feed feed)
The schemas describe the model when every value coerced, so validating a parsed feed against them tells you where a value was kept as a string. Enumerations stay strings, since feeds vary their case.
The feed model as Malli schemas, made from the tag registry so that they
never drift from the model.
The schemas are plain data in the vector syntax of Malli, and Malli
itself isn't a dependency. In your own project, give a schema to
malli.core/schema, and validate, explain, generate or coerce with it:
(m/validate schema/feed feed)
The schemas describe the model when every value coerced, so validating
a parsed feed against them tells you where a value was kept as a
string. Enumerations stay strings, since feeds vary their case.The shows and episodes of feeds as documents for full-text search with dk.simongray.drop-in-search.
The shows and episodes of feeds as documents for full-text search with dk.simongray.drop-in-search.
The vocabulary of the library as data: every element that it understands.
Each entry of elements describes one XML element: what the model calls it, where it can appear, how often, and the types of its attributes and text. The keys of an entry are:
The vocabulary of the library as data: every element that it understands. Each entry of elements describes one XML element: what the model calls it, where it can appear, how often, and the types of its attributes and text. The keys of an entry are: - :tag, the XML name, namespaced by alias, e.g. :podcast/transcript or :title - :key, the key in the model, or a map from parent to key when the element has another name under another parent - :parent, where it can appear: :channel, :item, which covers podcast:liveItem too, or the tag of its parent element - :count, :one or :many, or a map from parent to either - :attrs, the specified attributes by XML name, each with an optional :type from dk.simongray.podcast.feed.coerce/types, :required, and :values for an :enum, or the media types that a :mime takes - :content, the type of the node text, :none when the element has none, or :presence for an empty element that's true when it's there - :values, the allowed values of the node text of an enumeration - :deprecated, true, or the tag that replaces it - :children-as, the context to look up the children of the element in, when it borrows those of another element, as liveItem does with item - :doc, one line from the specification
Validation of a feed map by the tag registry.
A validation reports what the specifications say is wrong:
The :requirements option adds the required and recommended elements of a directory or a standard: :psp-1 for the baseline of the Podcast Standards Project, and :apple, :spotify and :youtube for those directories.
A diagnostic has the shape of those of the parse report: an :id, a :level of :error, :warning or :info, a :path into the feed, and a :message. Validation never throws on a value, since a shape that the element doesn't take is a diagnostic like any other.
Validation of a feed map by the tag registry. A validation reports what the specifications say is wrong: - a required attribute that's missing - a value outside an enumeration, or one that doesn't coerce to its type - a deprecated element - a broken structure, e.g. an alternate enclosure without a source - an element that the registry doesn't know The :requirements option adds the required and recommended elements of a directory or a standard: :psp-1 for the baseline of the Podcast Standards Project, and :apple, :spotify and :youtube for those directories. A diagnostic has the shape of those of the parse report: an :id, a :level of :error, :warning or :info, a :path into the feed, and a :message. Validation never throws on a value, since a shape that the element doesn't take is a diagnostic like any other.
Chapters in the two forms podcasts carry them: the JSON Chapters file that podcast:chapters links to, and Podlove Simple Chapters embedded in the feed as psc:chapters. Both read into one shape and write back out.
A chapters document is a map:
{:version "1.2.0"
:title "Episode 7" :author "…" :podcast-name "…" ; optional
:chapters [{:start-time 94 :title "Donation Segment"
:img "https://…" :url "https://…" :toc false
:end-time 120 :location {:name "…" :geo "geo:…"}}]}
Times are seconds. Chapters keep the order they're given in, and the keys that the format doesn't define are kept too. Reading never throws on content, and what it had to forgive is in the report, in the metadata.
Chapters in the two forms podcasts carry them: the JSON Chapters file
that podcast:chapters links to, and Podlove Simple Chapters embedded in
the feed as psc:chapters. Both read into one shape and write back out.
A chapters document is a map:
{:version "1.2.0"
:title "Episode 7" :author "…" :podcast-name "…" ; optional
:chapters [{:start-time 94 :title "Donation Segment"
:img "https://…" :url "https://…" :toc false
:end-time 120 :location {:name "…" :geo "geo:…"}}]}
Times are seconds. Chapters keep the order they're given in, and the keys
that the format doesn't define are kept too. Reading never throws on
content, and what it had to forgive is in the report, in the metadata.HLS playlists, the streams that feeds offer as alternate enclosures: what a multivariant playlist says of its variants and renditions, and what a media playlist says of its duration.
A multivariant playlist reads as a map like this, with the URLs made absolute against the playlist's own:
{:kind :multivariant
:version 6
:variants [{:url "https://…/720p.m3u8" :bandwidth 1352370
:average-bandwidth 831345
:codecs "hvc1.1.6.L93.90,mp4a.40.2"
:width 1280 :height 720 :frame-rate 29.97
:subtitles "subs"}]
:renditions [{:type :subtitles :group-id "subs" :name "English"
:language "en" :default? true :autoselect? true
:url "https://…"}]}
A media playlist reads as one like this:
{:kind :media :target-duration 6 :segments 816 :duration 4895.9
:playlist-type "VOD" :ended? true}
The reading follows RFC 8216 and never throws on content.
HLS playlists, the streams that feeds offer as alternate enclosures:
what a multivariant playlist says of its variants and renditions, and
what a media playlist says of its duration.
A multivariant playlist reads as a map like this, with the URLs made
absolute against the playlist's own:
{:kind :multivariant
:version 6
:variants [{:url "https://…/720p.m3u8" :bandwidth 1352370
:average-bandwidth 831345
:codecs "hvc1.1.6.L93.90,mp4a.40.2"
:width 1280 :height 720 :frame-rate 29.97
:subtitles "subs"}]
:renditions [{:type :subtitles :group-id "subs" :name "English"
:language "en" :default? true :autoselect? true
:url "https://…"}]}
A media playlist reads as one like this:
{:kind :media :target-duration 6 :segments 816 :duration 4895.9
:playlist-type "VOD" :ended? true}
The reading follows RFC 8216 and never throws on content.The header of an image file, which is what a check of artwork needs.
The header gives the type of the image, its size in pixels, its color model and whether it can be transparent. The reader takes them from the first bytes and doesn't decode the image. It reads these formats:
A reading takes a view of the parts of the file at hand, as in dk.simongray.podcast.util.bytes.view, and gives the header, or the ranges that it still needs. An image embedded in text, e.g. the artwork of a chapter, is a data URL, which data-url and from-data-url write and read. Only colors, palette and dominant-color decode an image, through the platform, for the colours that an app can colour its UI by.
The header of an image file, which is what a check of artwork needs. The header gives the type of the image, its size in pixels, its color model and whether it can be transparent. The reader takes them from the first bytes and doesn't decode the image. It reads these formats: - PNG, by the W3C PNG specification - JPEG, by ITU-T T.81 - GIF87a and GIF89a - WebP, by RFC 9649, with the VP8 frame header of RFC 6386, section 9.1 A reading takes a view of the parts of the file at hand, as in dk.simongray.podcast.util.bytes.view, and gives the header, or the ranges that it still needs. An image embedded in text, e.g. the artwork of a chapter, is a data URL, which data-url and from-data-url write and read. Only colors, palette and dominant-color decode an image, through the platform, for the colours that an app can colour its UI by.
What a media file says about itself, apart from the feed.
A media file gives its duration, its chapters and its artwork. It's an MP3 file, an MP4 or M4A file, an Ogg file of Opus or Vorbis, or an AAC file of ADTS frames.
A reading takes a view of the parts of the file at hand, as in dk.simongray.podcast.util.bytes.view, and gives what they say, or the ranges that it still needs. A fetch of those ranges most often takes one or two requests and a few hundred kilobytes.
A host that inserts ads serves another file to each request, so the duration is that of the file that came.
The readers are strict, and their namespaces name their quirks. The :except option leaves out the quirks that it names.
What a media file says about itself, apart from the feed. A media file gives its duration, its chapters and its artwork. It's an MP3 file, an MP4 or M4A file, an Ogg file of Opus or Vorbis, or an AAC file of ADTS frames. A reading takes a view of the parts of the file at hand, as in dk.simongray.podcast.util.bytes.view, and gives what they say, or the ranges that it still needs. A fetch of those ranges most often takes one or two requests and a few hundred kilobytes. A host that inserts ads serves another file to each request, so the duration is that of the file that came. The readers are strict, and their namespaces name their quirks. The :except option leaves out the quirks that it names.
AAC files: the ADTS frames of ISO/IEC 14496-3, Annex 1.A, with the ID3v2 tags that often come before them.
The duration comes from the first of these that the file has:
The reading is strict: the audio starts right after the tags. The :except option leaves out the quirks of the tags that it names.
AAC files: the ADTS frames of ISO/IEC 14496-3, Annex 1.A, with the ID3v2 tags that often come before them. The duration comes from the first of these that the file has: - the samples of every frame, when the view holds the whole file - the average length of the first frames and the size of the file - the TLEN frame of a tag The reading is strict: the audio starts right after the tags. The :except option leaves out the quirks of the tags that it names.
The ID3v2 tags at the start of an MP3 or AAC file, and what they say about the file: its tags, its duration, its artwork and its chapters.
The tags come through a view of the file, and id3-clj reads them from their bytes, as dk.simongray.id3 describes. Its frames are true to the ID3 specs, and this namespace gives what they say in the shapes of podcast-clj, e.g. the chapters in seconds. The quirks of the reading and its diagnostics are those of id3-clj.
The ID3v2 tags at the start of an MP3 or AAC file, and what they say about the file: its tags, its duration, its artwork and its chapters. The tags come through a view of the file, and id3-clj reads them from their bytes, as dk.simongray.id3 describes. Its frames are true to the ID3 specs, and this namespace gives what they say in the shapes of podcast-clj, e.g. the chapters in seconds. The quirks of the reading and its diagnostics are those of id3-clj.
MP3 files: the ID3v2 tags at the start, and the MPEG audio frames after them.
ISO/IEC 11172-3 and 13818-3 define the header of a frame, and the reader takes MPEG 2.5 too, which is in neither.
The duration comes from the first of these that the file has:
The reading is strict: the audio starts right after the tags. The quirk :mp3/junk-before-audio skips bytes that are no frame, such as padding that the size of a tag leaves out. The :except option leaves out the quirks that it names, those of the tags too.
MP3 files: the ID3v2 tags at the start, and the MPEG audio frames after them. ISO/IEC 11172-3 and 13818-3 define the header of a frame, and the reader takes MPEG 2.5 too, which is in neither. The duration comes from the first of these that the file has: - the Xing or Info header, which LAME and most encoders write in the first frame - the VBRI header of the Fraunhofer encoder - the average bitrate of the first frames and the size of the file, which is exact for a constant bitrate The reading is strict: the audio starts right after the tags. The quirk :mp3/junk-before-audio skips bytes that are no frame, such as padding that the size of a tag leaves out. The :except option leaves out the quirks that it names, those of the tags too.
MP4 and M4A files, whose boxes ISO/IEC 14496-12 defines.
A reading gives the duration from the movie header, the chapters, the artwork, the codecs and size of the tracks that play, and whether the movie comes first. The chapters are those of a QuickTime chapter track, or else those of a Nero chpl box. The titles of a chapter track can have a link each, and a track of images can name an image for each chapter, as in the enhanced podcasts of GarageBand. The artwork is the covr item of the iTunes metadata.
The movie box can come before the media data or after it, so the reading walks the boxes at the top level. A reader that has only the start of the file learns where to look next.
A file written in fragments gives the duration of all its fragments in its mehd box. Without that box, the reading takes the end of the last fragment that the mfra index at the end of the file lists.
The reading is strict. The quirk :mp4/zero-duration takes the duration of the longest track when the movie header gives none. The :except option leaves it out.
MP4 and M4A files, whose boxes ISO/IEC 14496-12 defines. A reading gives the duration from the movie header, the chapters, the artwork, the codecs and size of the tracks that play, and whether the movie comes first. The chapters are those of a QuickTime chapter track, or else those of a Nero chpl box. The titles of a chapter track can have a link each, and a track of images can name an image for each chapter, as in the enhanced podcasts of GarageBand. The artwork is the covr item of the iTunes metadata. The movie box can come before the media data or after it, so the reading walks the boxes at the top level. A reader that has only the start of the file learns where to look next. A file written in fragments gives the duration of all its fragments in its mehd box. Without that box, the reading takes the end of the last fragment that the mfra index at the end of the file lists. The reading is strict. The quirk :mp4/zero-duration takes the duration of the longest track when the movie header gives none. The :except option leaves it out.
Ogg files of Opus or Vorbis audio: the pages of RFC 3533, the headers of RFC 7845 and of the Vorbis I specification, and the comments after them.
The duration is the granule position of the last page of the audio, which lies in the last 64 KB of the file, less the granule position where the audio starts. For Opus, the start is exact. For Vorbis, whose packets give their lengths only through the setup header, it's exact for audio that starts at zero, and right to within one block otherwise, e.g. for a recording of a live stream.
The chapters come from the CHAPTERxxx comments of Xiph's chapter extension, and the artwork from a METADATA_BLOCK_PICTURE comment.
The reading is strict: a page whose CRC doesn't match is no page. The quirk :ogg/image-type takes the type of a picture from its bytes when the type that it declares is loose. The :except option leaves out the quirks that it names.
Ogg files of Opus or Vorbis audio: the pages of RFC 3533, the headers of RFC 7845 and of the Vorbis I specification, and the comments after them. The duration is the granule position of the last page of the audio, which lies in the last 64 KB of the file, less the granule position where the audio starts. For Opus, the start is exact. For Vorbis, whose packets give their lengths only through the setup header, it's exact for audio that starts at zero, and right to within one block otherwise, e.g. for a recording of a live stream. The chapters come from the CHAPTERxxx comments of Xiph's chapter extension, and the artwork from a METADATA_BLOCK_PICTURE comment. The reading is strict: a page whose CRC doesn't match is no page. The quirk :ogg/image-type takes the type of a picture from its bytes when the type that it declares is loose. The :except option leaves out the quirks that it names.
Subscription lists as OPML 2.0, the format that every podcast app imports and exports.
A subscription list is a map:
{:title "My podcasts"
:date-created #inst "…" ; and the other head elements
:subscriptions [{:text "A show"
:xml-url "https://example.org/feed.xml"
:html-url "https://example.org"
:path ["Tech"]
:category ["Daily" "/News/Denmark"]
:created #inst "…"}]
:includes [{:text "A list"
:url "https://example.org/list.opml"
:path ["News"]}]
:comments [{:text "An audiobook"
:guid "…"}]}
The folders of the OPML become the :path of the subscriptions inside them, and back again. In an outline:
An outline that includes another OPML list becomes one of the :includes, with the :path of its folders. It has the type include, or the type link to a URL that ends in .opml.
An outline with isComment="true" is commented out, and so is all that's inside it. It becomes one of the :comments, with its attributes and the :path of its folders, and never a subscription, even with an xmlUrl. Other apps skip a comment, so it can name a show that they can't fetch, e.g. one of local files.
Subscription lists as OPML 2.0, the format that every podcast app
imports and exports.
A subscription list is a map:
{:title "My podcasts"
:date-created #inst "…" ; and the other head elements
:subscriptions [{:text "A show"
:xml-url "https://example.org/feed.xml"
:html-url "https://example.org"
:path ["Tech"]
:category ["Daily" "/News/Denmark"]
:created #inst "…"}]
:includes [{:text "A list"
:url "https://example.org/list.opml"
:path ["News"]}]
:comments [{:text "An audiobook"
:guid "…"}]}
The folders of the OPML become the :path of the subscriptions inside
them, and back again. In an outline:
- the categories become a vector, which OPML writes as one
comma-separated attribute of tags and slash-delimited paths
- the time it was created becomes an instant, as do the dates of the
head
- any other attribute stays a string
- the episodes inside it, as Overcast's extended export writes them,
become its :episodes, maps of their attributes, e.g.
{:enclosure-url "…" :played "1" :progress "754"}
An outline that includes another OPML list becomes one of the :includes,
with the :path of its folders. It has the type include, or the type link
to a URL that ends in .opml.
An outline with isComment="true" is commented out, and so is all that's
inside it. It becomes one of the :comments, with its attributes and the
:path of its folders, and never a subscription, even with an xmlUrl.
Other apps skip a comment, so it can name a show that they can't fetch,
e.g. one of local files.Transcripts in the four formats that the podcast namespace allows, read into one shape and written back: SRT, WebVTT, the JSON segments format and the constrained HTML form, plus plain text.
Each format has its reader, parse-srt, parse-vtt, parse-json, parse-html and parse-text, and parse picks one by the type or by the file. The first three have writers too, emit-srt, emit-vtt and emit-json.
A transcript is a map:
{:format :vtt ; :srt :json :html :text
:segments [{:start-time 0.0 :end-time 2.76 :speaker "Sarah"
:body "…"}]}
Times are seconds, and :end-time and :speaker are there when the source had them. A WebVTT cue with inline timestamps has the :runs of its text too, each with its :start-time and :text, e.g. a word each in a word-level transcript. Speakers are read from these:
Reading is lenient and never throws on content. The report says what it had to forgive, e.g. times written as strings, an SRT wrapped in HTML or a declared type that doesn't match the file. The report is in the metadata.
Transcripts in the four formats that the podcast namespace allows, read
into one shape and written back: SRT, WebVTT, the JSON segments format
and the constrained HTML form, plus plain text.
Each format has its reader, parse-srt, parse-vtt, parse-json, parse-html
and parse-text, and parse picks one by the type or by the file. The
first three have writers too, emit-srt, emit-vtt and emit-json.
A transcript is a map:
{:format :vtt ; :srt :json :html :text
:segments [{:start-time 0.0 :end-time 2.76 :speaker "Sarah"
:body "…"}]}
Times are seconds, and :end-time and :speaker are there when the source
had them. A WebVTT cue with inline timestamps has the :runs of its text
too, each with its :start-time and :text, e.g. a word each in a
word-level transcript. Speakers are read from these:
- the voice spans of WebVTT, which name the speaker of their cue, or of
the cues that follow too in a file that marks only the changes of
speaker
- the cite elements of HTML
- the fields of JSON
- the "Name:" or "[Name]:" prefixes of caption files, which mark a
change of speaker, so the name carries over the cues that follow
Reading is lenient and never throws on content. The report says what it
had to forgive, e.g. times written as strings, an SRT wrapped in HTML or
a declared type that doesn't match the file. The report is in the
metadata.The gpodder API as a sync backend of like-minded, for the subscriptions and episode actions that a gpodder server keeps.
Such servers are gpodder.net, the GPodder Sync app for Nextcloud, oPodSync and PinePods. They keep the data of apps such as AntennaPod, Kasts and gPodder. The design is in doc/sync.md.
The API names a show by its feed URL, and an episode by the URL of its media, where podcast-clj names them by guid. The requests and the answers of the API are in dk.simongray.podcast.gpodder.api, and a server of it is in dk.simongray.podcast.gpodder.server.
The gpodder API as a sync backend of like-minded, for the subscriptions and episode actions that a gpodder server keeps. Such servers are gpodder.net, the GPodder Sync app for Nextcloud, oPodSync and PinePods. They keep the data of apps such as AntennaPod, Kasts and gPodder. The design is in doc/sync.md. The API names a show by its feed URL, and an episode by the URL of its media, where podcast-clj names them by guid. The requests and the answers of the API are in dk.simongray.podcast.gpodder.api, and a server of it is in dk.simongray.podcast.gpodder.server.
The gpodder API as data: the request map of each call, and what the answer to it says, for any HTTP client to send and receive.
The paths are those of gpodder.net, which oPodSync and other servers speak too, and those of the GPodder Sync app for Nextcloud, or a map of paths of a server's own.
The gpodder API as data: the request map of each call, and what the answer to it says, for any HTTP client to send and receive. The paths are those of gpodder.net, which oPodSync and other servers speak too, and those of the GPodder Sync app for Nextcloud, or a map of paths of a server's own.
The gpodder API served over a profile, so that apps such as AntennaPod, Kasts and gPodder sync with a server of the listener.
The server answers the paths of gpodder.net under /api/2. It's a device of podcast-clj, whose state holds what the apps send, with each entry written by the device of the app.
The listener has one list of subscriptions, as in the GPodder Sync app for Nextcloud. A change counts from the time that it arrived here, so that an action that a device sends late still reaches the other devices.
The gpodder API served over a profile, so that apps such as AntennaPod, Kasts and gPodder sync with a server of the listener. The server answers the paths of gpodder.net under /api/2. It's a device of podcast-clj, whose state holds what the apps send, with each entry written by the device of the app. The listener has one list of subscriptions, as in the GPodder Sync app for Nextcloud. A change counts from the time that it arrived here, so that an action that a device sends late still reaches the other devices.
The Nostr protocol: its events, its names and its relays. Nothing here is about podcasts.
An event is the JSON object of NIP-01 as plain data with string keys, as a relay gives it. Show no event whose id and signature aren't verified, since a relay can send anything.
The Nostr protocol: its events, its names and its relays. Nothing here is about podcasts. An event is the JSON object of NIP-01 as plain data with string keys, as a relay gives it. Show no event whose id and signature aren't verified, since a relay can send anything.
NIP-44, the encrypted payloads of Nostr, in its version 2, on both platforms. Nostr Wallet Connect sends its requests and its answers in such payloads.
Two owners of keys share a conversation key, under which each payload between them is encrypted. A payload is base64 text: a version, a nonce, the encrypted text and a check of the two.
The text is encrypted with ChaCha20 (RFC 8439), which is here since the Web Crypto API of the browsers lacks it, and the check is an HMAC with SHA-256 (RFC 2104). The keys of one payload come from the conversation key and the nonce by HKDF (RFC 5869). The text gets padding, so that the payload tells little of its length.
NIP-44 asks that a payload is in a signed event, and that a reader checks the signature before it reads the payload.
NIP-44, the encrypted payloads of Nostr, in its version 2, on both platforms. Nostr Wallet Connect sends its requests and its answers in such payloads. Two owners of keys share a conversation key, under which each payload between them is encrypted. A payload is base64 text: a version, a nonce, the encrypted text and a check of the two. The text is encrypted with ChaCha20 (RFC 8439), which is here since the Web Crypto API of the browsers lacks it, and the check is an HMAC with SHA-256 (RFC 2104). The keys of one payload come from the conversation key and the nonce by HKDF (RFC 5869). The text gets padding, so that the payload tells little of its length. NIP-44 asks that a payload is in a signed event, and that a reader checks the signature before it reads the payload.
Schnorr signatures over the curve secp256k1, as BIP-340 defines them, on both platforms. Nostr signs each event with such a signature.
Each key, message and signature is hexadecimal text, which is how Nostr writes them. A secret key can be a hidden text of dk.simongray.wary-fetch.secret. A verification reads no secret key, so the time that it takes tells nothing that's secret, but the time of anything that reads a secret key depends on it.
The numbers are BigInteger on the JVM and BigInt in JavaScript. A point of the curve is a vector of x, y and z in Jacobian coordinates, and nil is the point at infinity.
Schnorr signatures over the curve secp256k1, as BIP-340 defines them, on both platforms. Nostr signs each event with such a signature. Each key, message and signature is hexadecimal text, which is how Nostr writes them. A secret key can be a hidden text of dk.simongray.wary-fetch.secret. A verification reads no secret key, so the time that it takes tells nothing that's secret, but the time of anything that reads a secret key depends on it. The numbers are BigInteger on the JVM and BigInt in JavaScript. A point of the curve is a vector of x, y and z in Jacobian coordinates, and nil is the point at infinity.
Signers of events with the listener's Nostr key: a key that the app holds, a browser extension of NIP-07, and a remote signer of NIP-46.
A signer is a map of two functions, each of which gives a value, or in ClojureScript a promise of one:
Sign with sign!, which checks that a signer signed what it was given. A secret key is a hidden text of dk.simongray.wary-fetch.secret, so it doesn't show in a log or at a REPL.
Signers of events with the listener's Nostr key: a key that the app holds, a browser extension of NIP-07, and a remote signer of NIP-46. A signer is a map of two functions, each of which gives a value, or in ClojureScript a promise of one: - :public-key-fn, of no arguments, which gives the public key in hexadecimal, or a map with a diagnostic under :error - :sign-fn, of an event of NIP-01 without its id, public key and signature, which gives the signed event, or a map with a diagnostic under :error Sign with sign!, which checks that a signer signed what it was given. A secret key is a hidden text of dk.simongray.wary-fetch.secret, so it doesn't show in a log or at a REPL.
Podping: word that a feed has changed, which hosts write to the Hive blockchain and anyone can read. A client then fetches the feed that changed instead of polling every feed on a schedule.
A consumer reads blocks from any Hive API node and picks out the custom_json operations that the podping writers post. The calls are JSON-RPC requests that any client can send as a POST, and a ping in the blocks of an answer looks like this:
{:block 110254833 :timestamp #inst "2026-09-26T16:44:57Z"
:account "podping.eee" :id "pp_podcast_update" :version "1.1"
:medium "podcast" :reason "update"
:iris ["https://media.rss.com/…/feed.xml"]}
Anyone can write a custom_json, so only the accounts that the podping account follows on the chain may write podpings, which is how the project publishes its writers.
Polling is on the JVM only. In ClojureScript, send the calls one by one, since the nodes answer a page from any origin.
A host announces a change through podping.cloud, with the token that the host was given.
Podping: word that a feed has changed, which hosts write to the Hive
blockchain and anyone can read. A client then fetches the feed that
changed instead of polling every feed on a schedule.
A consumer reads blocks from any Hive API node and picks out the
custom_json operations that the podping writers post. The calls are
JSON-RPC requests that any client can send as a POST, and a ping in the
blocks of an answer looks like this:
{:block 110254833 :timestamp #inst "2026-09-26T16:44:57Z"
:account "podping.eee" :id "pp_podcast_update" :version "1.1"
:medium "podcast" :reason "update"
:iris ["https://media.rss.com/…/feed.xml"]}
Anyone can write a custom_json, so only the accounts that the podping
account follows on the chain may write podpings, which is how the
project publishes its writers.
Polling is on the JVM only. In ClojureScript, send the calls one by
one, since the nodes answer a page from any origin.
A host announces a change through podping.cloud, with the token that
the host was given.The listener's profile, which syncs between their devices, as plain data with pure functions over it.
A profile is one map:
{:device "8f1c9e2a-…" ; this device's id
:clock #inst "…" ; the latest stamp written or merged
:subscriptions {feed-guid {:url :title :image :added-at :guid
:removed?}}
:show-ranks {feed-guid {:rank "V" :removed? false}} ; the order
:remote-items {id {:feed-url :removed?}} ; episodes of other shows
:settings {feed-guid {:skip-intro 20 ... :stamps {…}}}
:tags {feed-guid {"Tech" {:removed? false}}}
:lists {url {:title :folder :removed?}} ; followed OPML lists
:episodes {id {:position :duration :reported-at}}
:played {id {:played? true :played-at}}
:hidden {id {:hidden? true}} ; as the listener chose
:archive {feed-guid {device-id {:items #{item-guid} :n 1}}}
:queue {id {:rank "V" :removed? false}}
:queue-moves {id {:rank "W" :removed? false}} ; moved ranks
:playlists {name {id {:rank "V" :removed? false}}}
:playlist-moves {name {id {:rank "W" :removed? false}}}
:inbox {id {:removed? false}}
:stars {id {:removed? false}}
:bookmarks {id {754 {:title :removed?}}}
:clips {id {754 {:duration :title :removed?}}}
:ratings {id {:rating 4 :worst 1 :best 5 :removed?}}
:filters {name {:criteria {:where {:status :unplayed}} :removed?}}
:listened {feed-guid {"2026-09" {device-id {:seconds 5400.0}}}}
:payments {id {:status :paid :sats 50 :address :removed?}}
:preferences {:skip-forward {:value 30} ...}
:devices {device-id {:name :platform ... :seen {…}}}
:retired {device-id {}}}
A profile is a CRDT of dk.simongray.mind-meld: each write takes the time it happens at, and stamps what it changed with that time and the :device of the profile. It syncs through dk.simongray.like-minded with the sync-options. A feed is identified by its key, the guid that its first fetch settled on, and an episode by its id, [feed-guid item-guid]. Once nothing has changed a played episode for a year, compact moves it from :episodes and :played to the :archive of its show.
This namespace has the subscriptions, their settings, the preferences and the progress of the episodes. The other parts have namespaces of their own:
The listener's profile, which syncs between their devices, as plain
data with pure functions over it.
A profile is one map:
{:device "8f1c9e2a-…" ; this device's id
:clock #inst "…" ; the latest stamp written or merged
:subscriptions {feed-guid {:url :title :image :added-at :guid
:removed?}}
:show-ranks {feed-guid {:rank "V" :removed? false}} ; the order
:remote-items {id {:feed-url :removed?}} ; episodes of other shows
:settings {feed-guid {:skip-intro 20 ... :stamps {…}}}
:tags {feed-guid {"Tech" {:removed? false}}}
:lists {url {:title :folder :removed?}} ; followed OPML lists
:episodes {id {:position :duration :reported-at}}
:played {id {:played? true :played-at}}
:hidden {id {:hidden? true}} ; as the listener chose
:archive {feed-guid {device-id {:items #{item-guid} :n 1}}}
:queue {id {:rank "V" :removed? false}}
:queue-moves {id {:rank "W" :removed? false}} ; moved ranks
:playlists {name {id {:rank "V" :removed? false}}}
:playlist-moves {name {id {:rank "W" :removed? false}}}
:inbox {id {:removed? false}}
:stars {id {:removed? false}}
:bookmarks {id {754 {:title :removed?}}}
:clips {id {754 {:duration :title :removed?}}}
:ratings {id {:rating 4 :worst 1 :best 5 :removed?}}
:filters {name {:criteria {:where {:status :unplayed}} :removed?}}
:listened {feed-guid {"2026-09" {device-id {:seconds 5400.0}}}}
:payments {id {:status :paid :sats 50 :address :removed?}}
:preferences {:skip-forward {:value 30} ...}
:devices {device-id {:name :platform ... :seen {…}}}
:retired {device-id {}}}
A profile is a CRDT of dk.simongray.mind-meld: each write takes the time
it happens at, and stamps what it changed with that time and the :device
of the profile. It syncs through dk.simongray.like-minded with the
sync-options. A feed is identified by its key, the guid that its first
fetch settled on, and an episode by its id, [feed-guid item-guid].
Once nothing has changed a played episode for a year, compact moves it
from :episodes and :played to the :archive of its show.
This namespace has the subscriptions, their settings, the preferences
and the progress of the episodes. The other parts have namespaces of
their own:
- profile.queue, the queue, the inbox and the playlists
- profile.marks, the stars, bookmarks, clips and ratings
- profile.opml, the followed lists, and the subscriptions as OPML
- profile.player, the events of a player
- profile.payments, the record of the listener's paymentsThe marks that the listener puts on the episodes of a profile: stars, bookmarks, clips and ratings.
Each is a collection of the profile, so a mark syncs like the rest of it. The played mark is the profile's own, since dk.simongray.podcast.profile/progress sets it.
The marks that the listener puts on the episodes of a profile: stars, bookmarks, clips and ratings. Each is a collection of the profile, so a mark syncs like the rest of it. The played mark is the profile's own, since dk.simongray.podcast.profile/progress sets it.
The OPML of a profile: the subscription lists that the listener follows, and the subscriptions as an OPML subscription list, out and in.
A followed list subscribes the listener to the shows that join it, in its folder. The subscription lists are the maps of dk.simongray.podcast.format.opml, which reads and writes the OPML itself.
The OPML of a profile: the subscription lists that the listener follows, and the subscriptions as an OPML subscription list, out and in. A followed list subscribes the listener to the shows that join it, in its folder. The subscription lists are the maps of dk.simongray.podcast.format.opml, which reads and writes the OPML itself.
The record of the listener's payments in a profile, so that a payment that went to the wallet isn't sent again, on this device or another.
Each payment to a recipient is an entry under its id, as value.recipients/pay! gives it, and the last write wins:
{:status :paid ; or :sending, :failed or :skipped
:sats 50
:address "ann@example.org" :type "lnaddress" :name "Ann"
:preimage "…" :proven? true :invoice "lnbc…"
:error :value/wallet-silent ; the id of the diagnostic
:removed? false}
Record a payment as :sending just before it goes to the wallet, and again with what the wallet answered. One that is still :sending after a crash, or on another device after a sync, may be paid, so ask the wallet rather than sending it again.
The record of the listener's payments in a profile, so that a payment
that went to the wallet isn't sent again, on this device or another.
Each payment to a recipient is an entry under its id, as
value.recipients/pay! gives it, and the last write wins:
{:status :paid ; or :sending, :failed or :skipped
:sats 50
:address "ann@example.org" :type "lnaddress" :name "Ann"
:preimage "…" :proven? true :invoice "lnbc…"
:error :value/wallet-silent ; the id of the diagnostic
:removed? false}
Record a payment as :sending just before it goes to the wallet, and
again with what the wallet answered. One that is still :sending after a
crash, or on another device after a sync, may be paid, so ask the
wallet rather than sending it again.The events of a player for a profile: what playing, skipping and reaching the end of an episode change in it, and the effects that tell the UI what to do next.
The events of a player for a profile: what playing, skipping and reaching the end of an episode change in it, and the effects that tell the UI what to do next.
The queue, the inbox and the playlists of a profile, and what the new episodes of a feed do to them.
The queue and the playlists are ordered collections of mind-meld, so the devices agree on their order, and a move never brings back an episode that another device took out. A playlist is kept beside the queue, and goes out as a list feed of the podcast namespace.
The queue, the inbox and the playlists of a profile, and what the new episodes of a feed do to them. The queue and the playlists are ordered collections of mind-meld, so the devices agree on their order, and a move never brings back an episode that another device took out. A playlist is kept beside the queue, and goes out as a list feed of the podcast namespace.
Stand-ins for the network in the tests of an app built on podcast-clj: a sender that answers each request from a map, and the XML of a podcast feed to answer with.
Give the sender as the :send of a library's config, a store/memory of like-minded as the store of a sync, and a :now-fn as the clock, and a test runs the library with no network and no time passing, as the guide shows.
Stand-ins for the network in the tests of an app built on podcast-clj: a sender that answers each request from a map, and the XML of a podcast feed to answer with. Give the sender as the :send of a library's config, a store/memory of like-minded as the store of a sync, and a :now-fn as the clock, and a test runs the library with no network and no time passing, as the guide shows.
Bech32, the encoding of BIP-173, on both platforms.
A Bech32 text has a prefix that a person reads, the separator 1, and data in an alphabet of 32 characters. The last six characters of the data are a checksum. The names of Nostr are Bech32, as NIP-19 defines them.
The section Segwit address format of BIP-173 tells how eight bits of data go into characters of five bits. Bech32m, the checksum of BIP-350, isn't here.
Bech32, the encoding of BIP-173, on both platforms. A Bech32 text has a prefix that a person reads, the separator 1, and data in an alphabet of 32 characters. The last six characters of the data are a checksum. The names of Nostr are Bech32, as NIP-19 defines them. The section Segwit address format of BIP-173 tells how eight bits of data go into characters of five bits. Bech32m, the checksum of BIP-350, isn't here.
The reading of a file by the ranges of its bytes that a reader needs, fetched in rounds within limits, for the readers of views.
A reader of a view gives the ranges that it lacks, and read! fetches them and reads again, until the reader has what it needs. The bytes come from a fetch function: an http/fetcher for a file on the web, or a fetcher of a local file or a Blob.
The reading of a file by the ranges of its bytes that a reader needs, fetched in rounds within limits, for the readers of views. A reader of a view gives the ranges that it lacks, and read! fetches them and reads again, until the reader has what it needs. The bytes come from a fetch function: an http/fetcher for a file on the web, or a fetcher of a local file or a Blob.
Views of files whose bytes come in parts, for the readers of media files and images, on both platforms.
A view holds the parts of a file that are there, each at its offset. A reader asks the view for the ranges that it needs before it reads them. A range that the view doesn't hold throws an exception that holds the ranges, so that the caller can fetch them and read again. A range past the end of the file throws too. The fetching is in util.bytes.ranges.
Views of files whose bytes come in parts, for the readers of media files and images, on both platforms. A view holds the parts of a file that are there, each at its offset. A reader asks the view for the ranges that it needs before it reads them. A range that the view doesn't hold throws an exception that holds the ranges, so that the caller can fetch them and read again. A range past the end of the file throws too. The fetching is in util.bytes.ranges.
The requests of podcast-clj, which name it as their User-Agent unless they name another, e.g. by the :user-agent option.
The requests of podcast-clj, which name it as their User-Agent unless they name another, e.g. by the :user-agent option.
Character and entity references in the text of feeds, HTML's included, which XML alone doesn't know.
Character and entity references in the text of feeds, HTML's included, which XML alone doesn't know.
The JSON adapter (RFC 8259): strings in, plain data with string keys out, and back, on both platforms.
The JSON adapter (RFC 8259): strings in, plain data with string keys out, and back, on both platforms.
The keys of podcast-clj's maps, from the camelCase names of XML and JSON.
The keys of podcast-clj's maps, from the camelCase names of XML and JSON.
The Ring requests that the handlers of podcast-clj answer and the responses they give: the bodies, credentials, hosts and conditions of the requests.
The Ring requests that the handlers of podcast-clj answer and the responses they give: the bodies, credentials, hosts and conditions of the requests.
Dates and durations as they appear in feeds, parsed leniently and formatted canonically, from arithmetic and regular expressions alone so that the JVM and JavaScript agree.
Instants are inst values, java.util.Date or js/Date. Durations and timestamps within media are seconds: a long when whole, and a double otherwise.
Dates and durations as they appear in feeds, parsed leniently and formatted canonically, from arithmetic and regular expressions alone so that the JVM and JavaScript agree. Instants are inst values, java.util.Date or js/Date. Durations and timestamps within media are seconds: a long when whole, and a double otherwise.
Value for value: the arithmetic of podcast:value blocks, and the record of a payment.
The functions here give who is paid at a moment of playback, with the time splits of valueTimeSplit. They divide an amount into shares and fees, and make no payment, which is up to you.
Value blocks and recipients are maps of the feed model, e.g.:
{:type "lightning"
:method "keysend"
:suggested 0.00000005
:recipients [{:type "node" :address "…" :split 99 :fee false}]
:time-splits [...]}
A payment is a map of what an app sends with a payment to one recipient. It has one shape for the bLIP-10 record of a keysend payment and for the RSS Payment Metadata of the podcast namespace:
{:action :boost ; :boost, :stream or :auto
:msat 25000 ; to this recipient
:total-msat 100000 ; of the whole payment, before the splits
:message "Great episode!"
:position 754 ; seconds into the episode
:feed-guid "…" :feed-title "…"
:item-guid "…" :item-title "…"
:app-name "…" :sender-name "…"
:recipient-name "…"}
The texts of a payment are as its sender wrote them. Hiccup escapes them, but an app that writes HTML as text must escape them itself.
Value for value: the arithmetic of podcast:value blocks, and the record
of a payment.
The functions here give who is paid at a moment of playback, with the
time splits of valueTimeSplit. They divide an amount into shares and
fees, and make no payment, which is up to you.
Value blocks and recipients are maps of the feed model, e.g.:
{:type "lightning"
:method "keysend"
:suggested 0.00000005
:recipients [{:type "node" :address "…" :split 99 :fee false}]
:time-splits [...]}
A payment is a map of what an app sends with a payment to one recipient.
It has one shape for the bLIP-10 record of a keysend payment and for the
RSS Payment Metadata of the podcast namespace:
{:action :boost ; :boost, :stream or :auto
:msat 25000 ; to this recipient
:total-msat 100000 ; of the whole payment, before the splits
:message "Great episode!"
:position 754 ; seconds into the episode
:feed-guid "…" :feed-title "…"
:item-guid "…" :item-title "…"
:app-name "…" :sender-name "…"
:recipient-name "…"}
The texts of a payment are as its sender wrote them. Hiccup escapes
them, but an app that writes HTML as text must escape them itself.The invoices of BOLT 11, as far as an app that pays needs to read them: the network and the amount, the payment hash, the expiry, and the proof of payment.
An invoice is the text that a Lightning address or a wallet gives, e.g. lnbc2500u1…, and a wallet pays it. None of these functions checks the signature, since the wallet does when it pays. BOLT 11 is at https://github.com/lightning/bolts/blob/master/11-payment-encoding.md
The invoices of BOLT 11, as far as an app that pays needs to read them: the network and the amount, the payment hash, the expiry, and the proof of payment. An invoice is the text that a Lightning address or a wallet gives, e.g. lnbc2500u1…, and a wallet pays it. None of these functions checks the signature, since the wallet does when it pays. BOLT 11 is at https://github.com/lightning/bolts/blob/master/11-payment-encoding.md
Lightning addresses, which a podcast:valueRecipient of type lnaddress names, and how to pay them. An address has the form user@domain.
The requests ask the domain of an address, as LUD-16 and the podcast namespace define them, and the readers read the answers. The wallet of the app pays.
LNURL-pay gives an invoice for an amount. The domain gives the terms of the address: the callback, the amounts that it takes, and how long a comment can be. The callback gives the invoice, which must ask for the amount that was requested, as LUD-06 requires. The keysend lookup gives a node instead, to pay with keysend.
A reader goes by the JSON of an answer, not by its status, as LUD-01 asks. Services answer an error with any status, and some answer the keysend lookup with a web page.
Lightning addresses, which a podcast:valueRecipient of type lnaddress names, and how to pay them. An address has the form user@domain. The requests ask the domain of an address, as LUD-16 and the podcast namespace define them, and the readers read the answers. The wallet of the app pays. LNURL-pay gives an invoice for an amount. The domain gives the terms of the address: the callback, the amounts that it takes, and how long a comment can be. The callback gives the invoice, which must ask for the amount that was requested, as LUD-06 requires. The keysend lookup gives a node instead, to pay with keysend. A reader goes by the JSON of an answer, not by its status, as LUD-01 asks. Services answer an error with any status, and some answer the keysend lookup with a web page.
The RSS Payment Metadata of the podcast namespace: what a payment to a Lightning address is for. The comment of the payment tells it, and a URL that the comment names.
A payment to a Lightning address carries only a short comment, which has the action, a URL and the start of the message:
rss::payment::boost https://… Great episode!
The URL answers with the header x-rss-payment, which holds the whole payment as JSON. A payment is the map of dk.simongray.podcast.value.
The sender writes the metadata, and the page at the URL is public. So the metadata is a claim, not what the node received.
The RSS Payment Metadata of the podcast namespace: what a payment to a
Lightning address is for. The comment of the payment tells it, and a URL
that the comment names.
A payment to a Lightning address carries only a short comment, which
has the action, a URL and the start of the message:
rss::payment::boost https://… Great episode!
The URL answers with the header x-rss-payment, which holds the whole
payment as JSON. A payment is the map of dk.simongray.podcast.value.
The sender writes the metadata, and the page at the URL is public. So
the metadata is a claim, not what the node received.Nostr Wallet Connect, which NIP-47 defines: payments from the wallet of the listener, asked for over a relay of Nostr.
The listener gets a connection URI from the wallet. The URI holds a secret key, and whoever has it can spend from the wallet, so keep it as you keep a password, and show it to nobody. A connection hides the key, so a connection that's printed doesn't show it.
A command is data, a map of :method and :params. The content of each event is encrypted with NIP-44. The encryption of NIP-04, which NIP-47 keeps for old wallets, isn't written here, since NIP-04 has no check of a change, and its own text tells not to use it.
NIP-47 has the commands of invoices, and the command of keysend is in the extension NWC-04, in the repository nostr-wallet-connect/nwc. A wallet names the commands that it takes in its info event.
Nostr Wallet Connect, which NIP-47 defines: payments from the wallet of the listener, asked for over a relay of Nostr. The listener gets a connection URI from the wallet. The URI holds a secret key, and whoever has it can spend from the wallet, so keep it as you keep a password, and show it to nobody. A connection hides the key, so a connection that's printed doesn't show it. A command is data, a map of :method and :params. The content of each event is encrypted with NIP-44. The encryption of NIP-04, which NIP-47 keeps for old wallets, isn't written here, since NIP-04 has no check of a change, and its own text tells not to use it. NIP-47 has the commands of invoices, and the command of keysend is in the extension NWC-04, in the repository nostr-wallet-connect/nwc. A wallet names the commands that it takes in its info event.
The payment map of dk.simongray.podcast.value and the JSON fields of its formats, the bLIP-10 record and the RSS Payment Metadata.
A format names the type of each of its fields. A reader reads the fields of those types into a payment and diagnoses what it forgave, and a writer writes the keys of a payment under the names of the format.
The payment map of dk.simongray.podcast.value and the JSON fields of its formats, the bLIP-10 record and the RSS Payment Metadata. A format names the type of each of its fields. A reader reads the fields of those types into a payment and diagnoses what it forgave, and a writer writes the keys of a payment under the names of the format.
No vars found in this namespace.
The recipients of the value blocks of a feed: what's wrong with them, what their Lightning addresses answer, and paying them all at once.
A host or a validator checks a feed with problems and check!, and an app pays a boost or a stretch of a stream with pay!, through the wallet of the listener.
The recipients of the value blocks of a feed: what's wrong with them, what their Lightning addresses answer, and paying them all at once. A host or a validator checks a feed with problems and check!, and an app pays a boost or a stretch of a stream with pay!, through the wallet of the listener.
WebLN, the interface that a wallet gives a web page: payments from the wallet of the listener, in a browser.
A browser extension or a wallet app puts its provider at window.webln. The page asks for the use of the provider, and the wallet asks the listener. Then the wallet asks the listener again for each payment, unless the listener gave the page a budget.
The functions that call the provider are in ClojureScript only, and each gives a promise of its result, or of a map with a diagnostic under :error.
No standards body defines WebLN. The guide at webln.guide defines its methods, and doc/services.md tells when podcast-clj read it.
WebLN, the interface that a wallet gives a web page: payments from the wallet of the listener, in a browser. A browser extension or a wallet app puts its provider at window.webln. The page asks for the use of the provider, and the wallet asks the listener. Then the wallet asks the listener again for each payment, unless the listener gave the page a budget. The functions that call the provider are in ClojureScript only, and each gives a promise of its result, or of a map with a diagnostic under :error. No standards body defines WebLN. The guide at webln.guide defines its methods, and doc/services.md tells when podcast-clj read it.
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 |