Audio track front matter
Every field below goes between the --- fences at the top of an episode page.
Nothing here is stored in a database — the whole track is this block plus the
markdown body underneath it. The only thing the engine keeps outside the file is
the like count (AudioLikeStore), and comments, which come from the existing
page comments API.
The example this documents:
---
title: How the engine got started
audio: true
file: https://sepodesk.com/wp-content/plugins/sepodesk/Storage/Uploads/4c068810-7939-428a-b4a0-0b780adaebfe/6a8ef822d5b6b.mp3
image: /img/w1.jpeg
duration: 32:10
episode: 1
season: 1
published: 2026-08-01
host: Chanda M
guest: Dr Banda
summary: One line for the list view.
tags: build, history
download_requires_login: true
allow_download: true
explicit: false
---
The two that decide whether the partial runs at all
audio: true
Marks the page as a track. Without it (or without the page sitting in an
audio/, podcast/ or episodes/ folder) audio.php returns immediately and
the page renders as ordinary docs.
It is a flag, not a path. If you write audio: true the partial explicitly
ignores that value when looking for a file — the file comes from file:. You
can drop this line entirely if the page lives in audio/; folder position is
enough.
Accepted equivalents: none — it is either this key or the folder.
file:
The actual audio. Two forms behave differently:
Absolute URL (what your example uses)
file: https://sepodesk.com/.../6a8ef822d5b6b.mp3
The player uses it as-is. If download_requires_login is on, the download route
authenticates the listener and then sends a 302 to this URL — it never streams
the bytes itself, because it has no local file to read.
Site-relative path
file: /public/audio/ep1.mp3
Passed through $linkUrl, so it picks up the WordPress base path automatically.
For a gated download the route resolves it on disk, searching public/,
Content/Pages/, Content/Audio/, Storage/Uploads/ and the base path in that
order, then streams it with byte-range support so seeking works.
Aliases: audio_file, src, track.
Worth knowing given your URL: a public absolute URL stays public. See A note on
download_requires_loginat the end.
Display fields
title:
The episode heading, the link text in the list, and the label in the sticky player dock. Falls back to the filename if missing.
image:
Artwork. Square works best — 180×180 on the episode page, 66×66 in the list, both
object-fit: cover, so a wide image gets cropped to its centre. Run through
$assetUrl, so /img/w1.jpeg resolves against your install root. Omit it and the
slot renders as a neutral grey block rather than a broken image.
Aliases: artwork, cover.
duration: 32:10
Display only, and only until the file loads. It is what the listener sees
before playback starts; the moment the browser reads the file's metadata the
player replaces it with the real duration. Nothing validates it, so a wrong value
here is a cosmetic lie that corrects itself on play. Free text — 32:10,
1:04:22 and 32 min all render.
Alias: length.
episode: 1 / season: 1
Both are strings for display, joined into the byline as Ep 1 · Season 1 · ….
They do not sort the list — that's published and order. Leave season
out for a show that doesn't run seasons and it simply won't appear.
Alias for episode: ep.
published: 2026-08-01
Two jobs. Displayed as 1 Aug 2026, and used to sort the index newest first.
Parsed with strtotime, so 2026-08-01, 1 August 2026 and 2026-08-01 09:00
all work; ISO is the safe choice. An episode with no published sorts to the
bottom.
Alias: date.
host: / guest:
Byline under the title — "Hosted by Chanda M · With Dr Banda". guest is also
included in the index search text, so typing a guest's name filters the list to
their appearances. host is not searched (it's usually the same on every
episode).
Alias for host: author.
summary:
One line. Shown under the title on the episode page and appended after the stamp in the list row, clamped to two lines. Also searchable. Keep it to a sentence — this is the line someone skims when deciding what to play.
Alias: description.
tags: build, history
Comma or pipe separated, or a YAML list. Rendered as pills on the episode page and folded into the index search text. They are labels, not links — there is no tag archive page behind them.
group: Web Development
Puts the episode under a heading in the listing, and marks it as part of a
series. Free text — whatever you write becomes the heading, matched
case-insensitively, so Web Development and web development are the same
group. Leave it off and the episode falls under "Other episodes" at the bottom.
On the episode page the group shows as a coloured chip, and "More episodes" prefers other tracks from the same group before falling back to the rest of the folder. In the listing it also becomes a filter pill and is searchable, so typing "web" narrows to that series.
Aliases: series, category, collection.
explicit: false
true puts a small E badge next to the title in both views. Nothing else
changes; it does not gate playback or hide the episode.
Download behaviour
allow_download: true
Default is true, so you only ever need to write this to turn downloads off.
When false the download button disappears from both views, and the
/audio/download route refuses with a 403. Playback is unaffected — this hides
the download, not the episode.
Alias: download.
download_requires_login: true
Default false. Changes what the download button points at:
| Listener | false (default) | true |
|---|---|---|
| Signed out | direct link to the file | "Sign in to download" → login page |
| Signed in | direct link to the file | /audio/download?track=<slug>, behind AuthMiddleware |
Aliases: login_to_download, requireLogin.
Sorting
The listing sorts by group, then order, then published newest-first,
then title. Group comes first so a series' episodes stay together — the rest
only decides the order inside a group.
order: (optional, not in the example)
An integer, default 500, applied before the date sort. Everything at the
default falls back to newest-first, which is what a podcast usually wants. Set
order: 1 on one episode to pin it to the top of its group — a trailer, or the
one you want new listeners to start with.
The index page
The listing is a separate page. Only audio_dir is required:
---
title: Podcast
audio_dir: audio
per_page: 10
group_order: Web Development|Design|Ops
ungrouped_label: Other episodes
---
audio_dir — the folder to scan. Files starting with _ or index. are
skipped, so audio/index.md listing audio/ doesn't list itself. Anything you
write in the body renders above the episode list.
per_page — episodes per page, default 10. Paging is client-side: every
episode is in the HTML and the page shows a window of it, so searching and
filtering re-page instantly with no round trip. A group that spans two pages
gets its heading repeated at the top of the second one.
group_order — the order headings appear in, pipe or comma separated. Any
group you don't name here sorts alphabetically after the ones you do, and
ungrouped episodes always come last. Names match case-insensitively.
ungrouped_label — the heading for episodes with no group:, default
"Other episodes". Only appears when at least one other episode is grouped;
if nothing is grouped, the listing has no headings at all.
Download counts
Nothing to configure — every download is counted, and the number sits next to
the download button in both views. AudioDownloadStore keeps one row per person
per track with a running tally (signed-out grabs all share one ~guest row), so
a track's file stays the size of its audience rather than growing with every
click.
How a grab reaches the counter depends on the track:
- Gated (
download_requires_login: true) — the route serving the bytes counts it. Range requests are ignored, so a player seeking through the file doesn't inflate the number. - Direct — the file link never touches PHP, so the page posts a beacon to
/api/v2/audio/downloadedas it navigates.
Either way a session only counts once per track per minute, which keeps a double-click or a retrying client from turning into a write storm.
A note on download_requires_login
With your file: pointing at an absolute https://sepodesk.com/... URL, that
URL is fetchable by anyone who has it, signed in or not. The player puts it in the
page's HTML so playback works — which means the gate hides the download button,
not the file. Anyone who opens devtools can still grab the mp3.
That's a deliberate trade: you said listening should be open, and streaming through PHP costs a worker process per listener where a direct URL costs nothing.
If you do want the audio itself behind the login, two changes:
- Move the file somewhere the web server won't serve directly — under
Content/Audio/rather than a publicStorage/Uploads/URL — and write it as a relative path. - Point the player at the gated route for those tracks:
/audio/download?track=<slug>&stream=1. The route already handles Range requests, so seeking and scrubbing keep working.
Do that only for the episodes that need it. Every gated stream is a PHP process held open for the length of the episode, which on shared hosting is exactly the kind of thing that exhausts the worker pool.