Create the missing podcast from a web radio station with Docker, VLC and PHP
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:
- Docker containers.
- VLC used as a command line.
- Symfony 4 with Zend Feed to create a lightweight podcast feed.
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.