How to publish podcasts with the Hugo Static Site Generator

April 18, 2021 · 10 minutes read

Some of you may remember that I switched my websites over to Hugo a while ago. Not only is Hugo much easier to set up and generates sites much faster, it’s also very easy to adapt to your needs.
One such adaption was how I publish my podcasts.

Some people use Wordpress (with a plugin like Powerpress) or Squarespace to do it, but that’s a lot of overhead for something that’s essentially super simple. So Hugo (a static site generator) to the rescue.

But first, a little excursion into the technical side of podcasts.

How podcasts work from a technical perspective

For a listener to be able to play your podcast episode, multiple parts need to work together:

  • Your podcast client (Apple Podcasts, Castro, Spotify, …)
  • (The podcast directory, usually provided by Apple)
  • The server hosting the podcast

The audio file

All podcasts start out as audio files. Usually MP3 or AAC files as those work universally across all kinds of devices. MP3 is probably still the format that is supported by a wider range of devices, but AAC has become the quasi standard of the modern age.
Somehow, somewhere, this file needs to be hosted so that the client can download it. There are many different ways how to do it, e.g.:

  • Putting a file on a web server (the easiest way)
  • Uploading the file to a S3-compatible service (e.g. Amazon S3, Backblaze B2, …)
  • Using one of the podcast hosting companies (e.g. Libsyn)

Where you put it ultimately doesn’t matter as long as you have a publicly reachable URL to the file.

The Feed

The next part is a so called RSS feed. RSS stands for Really Simple Syndication and is essentially a way to encode content so that another computer can read it, in our case as XML file.

Think of it as a Table of Contents of your published podcast episodes. It lists the episodes, with titles, images, the description of the episode (e.g. your show notes).
As Apple essentially invented the podcast directory (more on that in a bit) it also standardized how this information is encoded. There are many special XML keys that you need to provide and it would be too much to list & explain them all. Instead, have a look at this article. And if you’re curious, here’s the RSS feed of one of my podcasts.

The feeds purpose is to provide all relevant information of your podcast to anyone interested, e.g. your listener’s podcast client. So it contains not only all (or at least recent) episodes, but also your podcasts’s title, description and artwork. Every time you publish a new episode, the feed needs to be updated to contain that episode so clients (who check your feed periodically for changes) can pick it up and present it to your listeners.

Services like Libsyn not only host your files but usually also generate the feed for you.

The Apple Podcast Directory

Apple’s podcast directory is THE place where podcasts are listed. It’s a free service offered by Apple and anyone can list their podcast, no matter how big or small their listenership may be. Other than illegal content, there are no restrictions what can be listed. A lot of apps and websites use this directory to look for (new) podcasts. It’s not the only directory out there, but it’s the one that’s used by virtually anyone.

How do you get your podcast in? Well, you publish your feed, and then you register the feed URL in PodcastsConnect/iTunesConnect. That’s it.

Just make sure your feed is valid.

Publishing your podcast in Hugo

Now that we have a deeper understanding how everything is works on a technical level, we can talk about using Hugo to publish your podcasts.

What you need:

  • A Hugo site with your chosen template already set up
  • Your (first) podcast audio file already uploaded (you need the URL)

What we’re going to do:

  • Publish two separate feeds, one for AAC one for MP3
  • Provide a template for your podcast episodes, including an audio player
  • Provide a podcast subscription dialog where people can subscribe using their favorite podcast client.

The config file

Hugo already provides a lot of built in things we can use. Additionally we need to define some variables we will later use in our templates. This way, we can easily change things if we need to down the line (plus, you can reuse the templates for other podcasts…)

Add the following things to your config.yaml file. If you use TOML instead of YAML, just reformat the code to match the TOML formatting.

Step 1: Output formats

Hugo can already generate feeds, so all we need to do is tell it what to do.

outputFormats:
  aacfeed:
    MediaType: "application/rss+xml"
    BaseName: "index"
    path: "/feed/aac"
    IsHTML: false
    IsPlainText: true
    noUgly: true
    Rel: "alternate"
  mp3feed:
    MediaType: "application/rss+xml"
    BaseName: "index"
    path: "/feed/mp3"
    IsHTML: false
    IsPlainText: true
    noUgly: true
    Rel: "alternate"

outputs:
  home:
    - HTML
    - AMP
    - RSS
    - aacfeed
    - mp3feed

As you can see, we defined two feeds, one for AAC one for MP3.

Step 2: Parameters

Here we define the variables we will need later on.

params:
  author:
    name: Patrice
    email: <email>
  twitter: "@foodieflashback" 

  mainSections: ["blog", "episodes"] # see https://gohugo.io/functions/where/#mainsections

  podcast:
    description: <podcast description>
    tagline: Foodie Flashback - Food & Memories
    download_url: https://cdn.foodieflashback.com/episodes/
    cdn_url: https://cdn.foodieflashback.com/
    itunes_url: https://podcasts.apple.com/us/podcast/foodie-flashback/id1504783815
    itunes_id: id1504783815

I took these from my podcast Foodie Flashback.

Here we also define a section called “episodes” that will contain our podcast episodes. This way you can still write “normal” blog posts.

As you can see, I publish my episodes on a CDN (Backblaze B2), but you could just point it to the same URL as your site. It doesn’t matter really.

The episode template

Create a file in layouts/episodes/episode.html and define how your podcast page should look like.

A few helpful things:

Subscription box

I like using Podlove’s Subscription Button as it provides everything a listener would need.

Define a partial template in layouts/partials/podcast-subscribe.html:

<script>
    window.podcastData = {
        "title": "{{$.Site.Params.podcast.tagline}}",
        "subtitle": "{{$.Site.Params.podcast.tagline}}",
        "description": "{{$.Site.Title}}",
        "cover": "{{ $.Site.Params.podcast.cdn_url }}images/logo_feed.png",
        "feeds": [
            {
                "type": "audio",
                "format": "mp3",
                "url": "{{$.Site.BaseURL}}feed/mp3/",
                "variant": "high"
            },
            {
                "type": "audio",
                "format": "aac",
                "url": "{{$.Site.BaseURL}}feed/aac/",
                "variant": "high",
                "directory-url-itunes": "{{ $.Site.Params.podcast.itunes_url }}"
            }
        ]
    }
</script>

<script class="podlove-subscribe-button" src="{{$.Site.BaseURL}}subscribe-button/javascripts/app.js"
        data-language="en" data-size="medium" data-json-data="podcastData" data-color="#ff3c5c" data-format="square"
        data-style="frameless" data-buttonid="podsub1" data-hide="true"></script>
<noscript><a href="{{$.Site.BaseURL}}feed/aac/">Subscribe to feed</a></noscript>
<div class="podlove-subscribe-button-podsub1 subscribe"></div>

Then include it in the episode template:

<div class='post_extra mb-2'>
  <div class="mr-1">
    {{ partialCached "podcast-subscribe.html" . }}
  </div>
  {{ partial "share.html" . }}
</div>

Episode metadata

  • Episode date (= publish date) .Date
  • Episode Number .Params.number
  • Episode Title .Title

Additionally, the file urls are defined in the episodes frontmatter as well (more on that later)

In the episode template:

{{- $date := (dateFormat "02. January 2006" .Date) -}}
<p class='post_date pale'>{{ $date }}</p>
<h1 class='post_title'>#{{.Params.number}}: {{ .Title }}</h1>
                  
<div class="post_downloads mb-2 mr-2">
	<div class="icon-mic">
	  <strong>Published:</strong>
	  <em>{{ .PublishDate.Format "Jan 02, 2006" }}</em>
	</div>
	<div class="icon-download"><strong>Download As:</strong> <a
        href="{{.Site.Params.podcast.download_url}}{{.Params.aac.asset_link}}" class="download-link">AAC
    M4A</a>, <a href="{{.Site.Params.podcast.download_url}}{{.Params.mp3.asset_link}}"
                class="download-link">MP3</a>
	</div>
	<div class="icon-download"><strong>Rate</strong> <a
        href="{{.Site.Params.podcast.itunes_url}}">on iTunes</a></div>
  </div>
</div>

Episode image with fallback

I use Hugo’s asset pipelines to process the artwork for each episode, but also provide a fallback to the podcast’s artwork in case there’s no artwork for the episode.

Each episode markdown file (that has an episode artwork) has a metadata section in the frontmatter. More on that later.

The template snippet looks like this:

<!-- Image -->
<div class="post_thumb mr-2 mb-2">
{{ if .Params.image }}
  {{ $originalImg := resources.Get .Params.image.url }}
  {{ if $originalImg }}
    {{ $thumbnailImg := $originalImg.Fit "800x600" }}
    <figure >
        <img class="post_thumbnail"
            alt="{{ .Params.image.alt | default .Title | markdownify | safeHTML }}"
            src="{{$thumbnailImg.RelPermalink}}"/>
        <figcaption class="figure-caption">{{ .Params.image.alt | default .Title | markdownify | safeHTML }}
        </figcaption>
    </figure>
  {{else}}
    <a href="{{ .Site.Params.podcast.cdn_url }}images/logo_wide.png"><img src="{{ .Site.Params.podcast.cdn_url }}images/logo_wide.png" alt="{{.Title}}" width="320"
                                      class="post_thumbnail"/></a>
  {{end}}
{{else}}
  <a href="{{ .Site.Params.podcast.cdn_url }}images/logo_wide.png"><img src="{{ .Site.Params.podcast.cdn_url }}images/logo_wide.png" alt="{{.Title}}" width="320"
                                    class="post_thumbnail"/></a>
{{end}}
</div>    

The Audio Player

I use the Podlove Web Player

<!-- Player -->
<div id="player" class="mb-2 mr-2"></div>
<script src="https://cdn.podlove.org/web-player/5.x/embed.js"></script>
<script>
(function(){ 
  var episode = {
    show: {
      title: "{{ .Site.Title}}",
      subtitle: "{{.Site.Params.podcast.tagline}}",
      summary: "{{.Site.Params.podcast.description}}",
      poster: "{{ .Site.Params.podcast.cdn_url }}images/logo_feed.png",
      link: ""
    },
    /**
    * Episode related Information
    */
    title: "{{ .Params.number }}. {{ .Params.title }}",
    subtitle: "{{ .Params.short_description | plainify }}",
    summary: "{{ .Content | plainify }}",
    // ISO 8601 DateTime format, this is capable of adding a time offset, see https://en.wikipedia.org/wiki/ISO_8601
    publicationDate: '{{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:00 -0700" }}',
    // ISO 8601 Duration format ([hh]:[mm]:[ss].[sss]), capable of add ing milliseconds, see https://en.wikipedia.org/wiki/ISO_8601
    duration: "{{ .Params.duration }}",
    link:"{{.Permalink}}",

    /**
    * Audio Assets
    * - media Assets played by the audio player
    * - format support depends on the used browser (https://en.wikipedia.org/wiki/HTML5_audio#Supported_audio_coding_formats)
    * - also supports HLS streams
    *
    * Asset
    * - url: absolute path to media asset
    * - size: file size in  byte
    * - (title): title to be displayed in download tab
    * - mimeType: media asset mimeType
    */
    audio: [
      {
        url:"{{.Site.Params.podcast.download_url}}{{ .Params.aac.asset_link | safeHTML }}",
        size: "{{ .Params.aac.length }}",
        title: "MPEG-4 AAC Audio (m4a)",
        mimeType: "audio/mp4"
      },
      {
        url:"{{.Site.Params.podcast.download_url}}{{ .Params.mp3.asset_link | safeHTML }}",
        size: "{{ .Params.mp3.length }}",
        title: "MP3 Audio (mp3)",
        mimeType: "audio/mpeg"
      }
    ],
  };
  
  var config={
    version: 5,
    share: {
      /**
      * Share Channels:
      * - list of available channels in share tab
      */
      channels: [
        "facebook",
        "twitter",
        "mail",
        "link"
      ],
      // share outlet, if not provided embed snippet is not available
      // outlet: "/share.html",
      sharePlaytime: true
    }
  };
})();
</script>

The Feed template

Last thing you need is a bunch of feed templates.

I created a generic template in layouts/partials/podcast-feed.xml

{{ `<?xml version="1.0" encoding="utf-8"?>` | safeHTML }}
<rss version="2.0"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:wfw="http://wellformedweb.org/CommentAPI/"
     xmlns:dc="http://purl.org/dc/elements/1.1/"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
     xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
     xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
     xmlns:media="http://search.yahoo.com/mrss/">
    <channel>
        {{ $assetFormat := .Scratch.Get "asset-format" }}
        <title>{{ .Site.Title}}</title>
        <atom:link href="{{.Site.BaseURL}}feed/{{$assetFormat}}/" rel="self" type="application/rss+xml"/>
        <link>{{.Site.BaseURL}}</link>
        <description>{{.Site.Params.podcast.description}}</description>
        <lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>
        <sy:updatePeriod>hourly</sy:updatePeriod>
        <sy:updateFrequency>1</sy:updateFrequency>
        <language>en</language>
        <copyright>Copyright &#xA9; {{.Site.Title}}</copyright>
        <managingEditor>{{.Site.Params.author.email}} ({{ .Site.Params.author.name }})</managingEditor>
        <webMaster>{{.Site.Params.author.email}} ({{ .Site.Params.author.name }})</webMaster>

        <itunes:new-feed-url>{{.Site.BaseURL}}feed/{{$assetFormat}}/</itunes:new-feed-url>
        <itunes:type>episodic</itunes:type>
        <itunes:subtitle>{{.Site.Params.podcast.tagline}}</itunes:subtitle>
        <itunes:summary>{{.Site.Params.podcast.description}}</itunes:summary>
        <itunes:keywords></itunes:keywords>
        <itunes:category text="Arts">
            <itunes:category text="Food"/>
        </itunes:category>
        <itunes:category text="Health &amp; Fitness">
            <itunes:category text="Nutrition"/>
        </itunes:category>
        <itunes:category text="Education">
            <itunes:category text="Courses"/>
        </itunes:category>
        <itunes:category text="History"/>

        <itunes:author>{{.Site.Params.author.name}}</itunes:author>
        <itunes:owner>
            <itunes:name>{{.Site.Params.author.name}}</itunes:name>
            <itunes:email>{{.Site.Params.author.email}}</itunes:email>
        </itunes:owner>
        <itunes:block>no</itunes:block>
        <itunes:explicit>no</itunes:explicit>
        <itunes:image href="{{ .Site.Params.podcast.cdn_url }}images/logo_feed.png"/>
        {{ range (where .Site.RegularPages "Params.layout" "episode")}}
        {{ .Scratch.Set "asset" .Params.aac }}
        {{if eq $assetFormat "mp3"}}
        {{ .Scratch.Set "asset" .Params.mp3 }}
        {{end}}
        <item>
            <link>{{.Permalink}}</link>
            <title>{{ .Params.number }}. {{ .Params.title }}</title>
            <pubDate>{{ .PublishDate.Format "Mon, 02 Jan 2006 15:04:00 -0700" }}</pubDate>
            <description>{{ .Params.short_description }}</description>

            <enclosure url='{{.Site.Params.podcast.download_url}}{{ (.Scratch.Get "asset").asset_link | safeHTML }}'
                       length='{{ (.Scratch.Get "asset").length }}' type="audio/{{$assetFormat}}"/>
            <guid>{{.Site.BaseURL}}{{ if .Params.guid }}{{ .Params.guid }}{{ else }}{{ .Params.number }}{{ end }}/</guid>

            <itunes:author>{{.Site.Params.author.name}}</itunes:author>
            <itunes:episode>{{ .Params.number }}</itunes:episode>
            <itunes:title>{{ .Params.title }}</itunes:title>
            <itunes:summary>{{ .Content | plainify }}</itunes:summary>
            <itunes:subtitle>{{ .Params.short_description | plainify }}</itunes:subtitle>
            <content:encoded>{{ `<![CDATA[` | safeHTML }}{{ .Content }}{{ `]]>` | safeHTML }}</content:encoded>
            {{ $scratch := newScratch }}
            {{ if isset .Params "image" }}
            {{ $originalImg := resources.Get .Params.image.url }}
            {{ if $originalImg }}
                {{ $thumbnailImg := $originalImg.Fit "3000x3000" }}
                {{ $scratch.Set "image" $thumbnailImg.RelPermalink }}
            {{else}}
                {{ $scratch.Set "image"  "images/thumbnail.svg" }}
            {{end}}
            {{ else }}
                {{ $scratch.Set "image" (printf "%simages/logo_feed.png" .Site.Params.podcast.cdn_url) }}
            {{ end }}
            {{ $image := $scratch.Get "image" }}
            <itunes:image
                    href="{{ (absURL $image) }}"/>
            <itunes:duration>{{ .Params.duration }}</itunes:duration>
            <itunes:keywords>{{ delimit .Params.tags ", " }}</itunes:keywords>
            <itunes:episodeType>{{ .Params.eptype }}</itunes:episodeType>
        </item>

        {{end}}

    </channel>
</rss>

Then created two feed templates:

In layouts/index.mp3feed.xml

{{- .Scratch.Set "asset-format" "mp3" -}}
{{partial "podcast-feed.xml" . | safeHTML}}

In `layouts/index.aacfeed.xml

{{- .Scratch.Set "asset-format" "aac" -}}
{{partial "podcast-feed.xml" . | safeHTML}}

As you can see, it uses a asset-format variable to configure the basic template to use the correct values.

The Episode file

This is what you’re going to write every time you publish a new episode.

Here’s the template I use:

---
author: Patrice Brend'amour
title: "Title"
layout: episode
eptype: full
number: episode_number, e.g. 16
duration: "episode duration e.g. 1:33:25"
date: when to publish, e.g. 2020-10-18 00:40:00 +02:00 
short_description: |
	Short blurb about this episode.
mp3:
    asset_link: name of your MP3 file, e.g. "foodieflashback_XX.mp3"
    length: size in bytes, e.g. 112109285
aac:
    asset_link: name of your AAC file, e.g. "foodieflashback_XX.m4a"
    length: size in bytes, e.g. 113126094
url: /<episode_number>
tags: []
image: 
  url: images/<episode_image>
  alt: "Source: <someone>"
draft: true/false (depending on whether it's ready to be published)
---

<Content/show notes/links>

Most keys should be pretty self explanatory.

Put a new markdown file under contents/episodes/ and generate your site.

And you’re done!

In a future post, I will show you how my writing, recording and editing workflow works using (mostly) my iPad. Stay tuned.