Create the missing podcast from a web radio station with Docker, VLC and PHP

How to record any radio stream with a script and using VLC in the command line.

As I recently moved to the US, I needed to get prepared a bit and brush up my English. One nice way to do so is to listen to news podcasts, like Morning Edition on NPR.

But there is no podcast for this show unfortunately. And when something's missing, better create it πŸ™‚

πŸ™ Code shown in this article is available on Github: https://github.com/michaelperrin/stream-podcast


Disclaimer β€” I don't know if it is authorized to record local public stations. I did read terms of use, but could not find information about it.

But as far as it is for my own usage and that I compensate by making a donation to the radio station I record, I think this is quite fair.

Anyway, this article is not only about recording NPR, but any audio stream.


That's an interesting case study that will use the following elements:

Record the audio stream

Create a Docker container for VLC

We are going to automatically record audio from a radio station stream into audio files using the command line version of VLC. It has the advantage to not depend on a whole lot of other packages and won't pollute your Docker container with X.org and graphic libraries.

To install the command line version of VLC, create a Docker container using the following Dockerfile definition:

πŸ“‚ docker β–Ή πŸ“‚ stream_recorder β–Ή πŸ“„ Dockerfile

FROM ubuntu:18.04
LABEL maintainer="MichaΓ«l Perrin"

# Install VLC command line tool
RUN apt-get update && apt-get install -y \
    vlc-bin \
    vlc-plugin-base

# Allow user to run VLC
RUN groupadd -g 999 appuser && \
    useradd -r -u 999 -g appuser appuser

# Fix permissions
RUN usermod -u 1000 appuser

CMD ["cron", "-f"]

ℹ️ I use a Ubuntu base image instead of an Alpine one because I could not find the VLC CLI package for Alpine.

Schedule your recordings with CRON

We are now going to define a crontab file with the list of audio streams you would like to record. If you are not at ease with the crontab format (who can be at ease with it?), have a look at https://crontab.guru/ which I highly recommend.

Here is an example of a crontab file I use:

πŸ“‚ docker β–Ή πŸ“‚ stream_recorder β–Ή πŸ“„ crontab

0 10 * * 1,2,3,4,5 appuser cvlc "http://yourradiostation.com/stream.pls" --sout "file/mp3:/audio/MYPODCAST-$(date '+\%Y-\%m-\%d').mp3" --run-time=3600 --stop-time=3600 vlc://quit

It means that:

  • The command is run every weekday, at 10.00am.
  • The command is run with the appuser user.
  • The command to be run in cvlc (command line VLC).
  • The recorded stream source is yourradiostation.com/stream.pls (could be any stream URL from a real radio station).
  • The stream is saved to a file in /audio and is named like MYPODCAST-2018-08-14.mp3 .
  • This stream is recorded during 3600 seconds (--run-time=3600 --stop-time=3600 vlc://quit)

Edit the previous Dockerfile file and add these line before the CMD command in order to install cron and add the crontab file to the container:

# ...

RUN apt-get update && apt-get install -y cron

ADD crontab /etc/cron.d/stream-recorder
RUN chmod 0644 /etc/cron.d/stream-recorder

# ...

ℹ️ Don't forget to build again the Docker image each time you edit the crontab file.

Manage containers with Docker Compose

Create a docker-compose.yml file to manage containers:

πŸ“„ docker-compose.yml

version: '3'
services:
  stream_recorder:
    build: ./docker/stream-recorder/
    volumes:
      - ./audio:/audio

The audio folder is shared with the host, so that the podcast we are going to create have access to the audio files.

Create the podcast

Using a custom lightweight PHP app could be possible but Symfony 4 has the advantage to be a very lightweight framework, so we are going to leverage its features for creating the podcast app.

Define podcast information

Let's create a file that will be read by our app to know about the list of podcasts that should be exposed:

πŸ“‚ docker β–Ή πŸ“‚ php β–Ή πŸ“„ podcasts.json

{
    "npr-morning-edition": {
        "title": "NPR Morning edition",
        "link": "https://www.npr.org",
        "description": "NPR Morning edition",
        "files": "MYPODCAST-*.mp3"
    }
}

Initiate the Symfony app

Add a container for PHP (I have embedded Composer in it, but that was not necessary):

πŸ“‚ docker β–Ή πŸ“‚ php β–Ή πŸ“„ Dockerfile

FROM composer:1.7 as composer
FROM php:7.2-fpm-alpine

ENV COMPOSER_ALLOW_SUPERUSER=1
ENV APCU_VERSION 5.1.8

# Add Composer to PHP container
COPY --from=composer /usr/bin/composer /usr/local/bin/composer

# Install recommended extensions for Symfony & Composer
RUN apk add --no-cache \
    ca-certificates \
    icu-libs \
    git \
    unzip \
    zlib-dev && \
    apk add --no-cache --virtual .build-deps \
    $PHPIZE_DEPS \
    icu-dev && \
    docker-php-ext-install \
        intl \
        zip && \
    pecl install apcu-${APCU_VERSION} && \
    docker-php-ext-enable apcu && \
    docker-php-ext-enable opcache

ADD podcasts.json /etc/stream-recorder/podcasts.json

Add it to Docker Compose:

πŸ“„ docker-compose.yml

services:
  # ...
  php:
    build: ./docker/php
    volumes:
      - ./app:/var/www/app
      - ./audio:/audio:ro
    working_dir: /var/www/app
    environment:
      AUDIO_BASE_URI: http://localhost/audio
      PODCASTS_FILE: /etc/stream-recorder/podcasts.json

Build and start containers:

docker-compose up --build -d

Install Symfony at the root of your project:

docker-compose exec php composer create-project symfony/skeleton .

Create a service that generates feeds

Zend Feed will be use to generate the XML content for the podcast we are going to generate. Add it as a dependency:

docker-compose exec php composer require zendframework/zend-feed

Install the Options Resolver component that will allow us to validate a bit data from the podcasts.json file:

docker-compose exec php composer require symfony/options-resolver

Create now the PodcastGenerator class:

πŸ“‚ app β–Ή πŸ“‚ src β–Ή πŸ“‚ Feed β–Ή 🐘 PodcastGenerator.php

<?php

namespace App\Feed;

use App\Feed\Exception\NotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Zend\Feed\Writer\Entry;
use Zend\Feed\Writer\Feed;

class PodcastGenerator
{
    private $router;
    private $podcasts;
    private $audioBaseUri;

    public function __construct(RouterInterface $router, array $podcasts, string $audioBaseUri)
    {
        $this->router = $router;
        $this->podcasts = $podcasts;
        $this->audioBaseUri = $audioBaseUri;
    }

    /**
     * Retrieves given feed
     *
     * @param string $name
     * @param string $uri
     * @return string
     * @throws NotFoundException
     */
    public function getFeed(string $name): string
    {
        if (!isset($this->podcasts[$name])) {
            throw new NotFoundException(sprintf('Podcast "%s" was not found', $name));
        }

        $properties = $this->podcasts[$name];

        $properties = $this->resolveProperties($properties);

        $feed = new Feed();

        $feed->setTitle($properties['title']);
        $feed->setLink($properties['link']);
        $feed->setFeedLink(
            $this->router->generate('podcast_feed', ['name' => $name]),
            $properties['feedType']
        );
        $feed->setDescription($properties['description']);

        $this->addEntries($feed, $properties['files']);

        return $feed->export($properties['feedType']);
    }

    protected function resolveProperties(array $properties): array
    {
        $resolver = new OptionsResolver();
        $resolver->setRequired(['title', 'link', 'description', 'files']);
        $resolver->setDefaults([
            'feedType' => 'rss',
        ]);

        return $resolver->resolve($properties);
    }

    protected function addEntries(Feed $feed, string $filenameFilter): void
    {
        $finder = new Finder();
        $finder->name($filenameFilter)->sortByName();

        $items = [];

        $latestTime = 0;

        foreach ($finder->in('/audio') as $file) {
            $fileInfo = $file->getFileInfo();

            $entry = $feed->createEntry();
            $this->populateEntryData($entry, $file);

            $feed->addEntry($entry);

            $latestTime = $file->getCTime() > $latestTime ? $file->getCTime() : $latestTime;
        }

        $feed->setDateModified(\DateTime::createFromFormat('U', $latestTime));
    }

    protected function populateEntryData(Entry $entry, \SplFileinfo $file): Entry
    {
        $uri = sprintf('%s/%s', $this->audioBaseUri, $file->getBasename());
        $dateCreated = \DateTime::createFromFormat('U', $file->getCTime());

        $entry->setTitle(sprintf('Episode %s', $dateCreated->format('Y-m-d'))); // Arbitrary title ;-)
        $entry->setLink($uri);
        $entry->setEnclosure(['uri' => $uri, 'type' => 'audio/mpeg', 'length' => $file->getSize()]);
        $entry->setDateCreated($dateCreated);
        $entry->setDateModified(\DateTime::createFromFormat('U', $file->getMTime()));
        $entry->setContent(sprintf('Episode %s', $dateCreated->format('Y-m-d')));

        return $entry;
    }
}

To make it more SOLID, I could of course created a service that retrieve stream paramaters, and one that generates the feed. But let's keep things simple here :)

Add the NotFoundException class:

πŸ“‚ app β–Ή πŸ“‚ src β–Ή πŸ“‚ Feed β–Ή πŸ“‚ Exception β–Ή 🐘 NotFoundException.php

<?php

namespace App\Feed\Exception;

class NotFoundException extends \Exception
{
}

Configure services:

πŸ“‚ app β–Ή πŸ“‚ config β–Ή πŸ“„ services.yaml

parameters:
    # ...
    podcasts: '%env(json:file:PODCASTS_FILE)%'
    audio_base_uri: '%env(AUDIO_BASE_URI)%'

services:
    # ...
    App\Feed\PodcastGenerator:
        arguments:
            $podcasts: '%podcasts%'
            $audioBaseUri: '%audio_base_uri%'

Add the controller

Create a controller for the podcast feed:

πŸ“‚ app β–Ή πŸ“‚ src β–Ή πŸ“‚ Controller β–Ή 🐘 FeedController.php

<?php

namespace App\Controller;

use App\Feed\PodcastGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class FeedController
{
    public function podcast(PodcastGenerator $podcastGenerator, string $name)
    {
        try {
            $feed = $podcastGenerator->getFeed($name);
        } catch (NotFoundException $e) {
            throw new NotFoundHttpException('The product does not exist');
        }

        $response = new Response($feed);
        $response->headers->set('Content-Type', 'xml');

        return $response;
    }
}

Add a webserver

Add Nginx to serve the Symfony app. Define the virtual host:

πŸ“‚ docker β–Ή πŸ“‚ nginx β–Ή πŸ“„ app.conf

upstream php-upstream {
    server php:9000;
}

server {
    root /var/www/app/public;

    location / {
        try_files $uri /index.php$is_args$args;
    }

    location ~ ^/index\.php(/|$) {
        fastcgi_pass php-upstream;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }

    location ~ \.php$ {
        return 404;
    }
}

And define the webserver container:

πŸ“„ docker-compose.yml

services:
  # ...
  webserver:
    image: nginx:1.12-alpine
    depends_on:
      - php
    volumes:
      - ./docker/nginx/app.conf:/etc/nginx/conf.d/default.conf:ro
      - ./app:/var/www/app
      # Make web server have access to the audio files
      - ./audio:/var/www/app/public/audio:ro
    ports:
      - 80:80

Access your podcast!

Build and start your Docker containers:

docker-compose rm --stop
docker-compose up --build -d

Audio files will automatically download to the audio directory according to your crontab entries and be served by the Symfony app.

You can now add your podcast to the iOS Podcast app, iTunes, Google Play Music, Podcast Addict or any other podcast app!

Access the podcast using the http://localhost/podcast/npr-morning-edition.xml or from your own server, or more generally to the /podcast/{podcast-entry-in-json}.xml URL.