I’ve been listening to a lot of audiobooks and podcasts recently. I like the Open Source AntennaPod Android app, so I wanted to listen to my audiobooks using that along with my podcast subscriptions. To start with, I copied the audiobooks onto my phone with a USB cable and added those in AntennaPod as a “local folder” subscription, but that wasn’t very convenient!

The dream was to have a proper audiobook subscription that would stay up to date as I copied books onto my NAS. That way, I could quickly use the app to download, queue and listen to books as I do with podcast episodes.

Screenshot of audiobooks feed displayed by AntennaPod.
Audiobooks feed displayed by AntennaPod.

I have a Synology DS918+ NAS. It’s set up to sleep when it has been inactive for a while to save power. While I wanted a convenient way to listen to audiobooks, I needed to avoid installing large packages or Docker images that could prevent the system from sleeping. Better to have a small PHP script to generate the audiobook RSS feed instead. That way, I could use Synology’s built-in web server.

I looked for existing PHP scripts and found a couple (e.g. here and here), but none worked quite how I wanted. I missed preview images, book descriptions and other details. It seemed a shame, especially since that was all available in the ID3 tags of the MP3 files produced by OpenAudible.

So I wrote my own script. I’ve been using it for a while now, and it works great. Here’s how to set it up:

Assumptions:

  • All audiobooks are thrown in a folder, along with an audiobooks.png cover image for the feed and the index.php script (see below).
  • All audiobooks are MP3/M4B files ending with .mp3 or .m4b.
  • Most of the audiobook files include proper metadata (e.g. title, author, publish date) and cover images.
  • The files are hosted on a Synology NAS with a static IP of 192.168.1.100.

Setup instructions:

  1. Add the SynoCommunity Package Source in the Package Center (instructions here).
  2. Install the following packages in the Synology Package Center:
    • Web Station
    • ffmpeg
    • PHP (e.g. PHP 5.6)
  3. Open the Web Station:
    • Click “General Settings” and ensure “HTTP back-end server” is set to “Nginx” and “PHP” is set to the installed PHP version.
    • Click “Virtual Host” and then “Create”:
      • Select “Port-based”.
      • Check “HTTP” and type “60808” for the port.
      • Select the folder containing your audiobook files for the “Document root” option.
      • Ensure “HTTP back-end server” and “PHP” options are set the same as in the “General Settings” screen.
      • Click “OK” to save and ensure the newly created entry is listed as “Status: Normal”.
  4. Create a file called index.php in your audiobook folder and save the PHP script contents (see below). Take care to edit the settings at the top of the script if necessary (e.g. to adjust the IP address).
  5. Right-click on your audiobook folder, select “Properties”, then select the “Permission” tab. Check that the group (usually second entry between username and “Everyone”) is set to “http”. If it isn’t (e.g. if it’s set to “users”), double-click the group name and in the dialogue that opens, then set “User or group” to “http”.
  6. Save an audiobooks.png cover art file in your audiobook folder. That serves as the feed cover, so something like a picture of books makes sense.
  7. Navigate to http://192.168.1.100:60808 in your browser and ensure an RSS feed is displayed. Wait for the page to load (it can take some time), do not refresh the page early!
  8. Now you can add the audiobooks feed in AttennaPod. Select “Add Podcast by RSS address” and enter http://192.168.1.100:60808.
  9. If you add new audiobooks, they should show up automatically when the feed is refreshed.

Notes:

  • The first time you load the RSS feed, expect it to be pretty slow. It takes a while to read all the metadata from the audiobook files and to save all the cover images. It’s important to wait for that work to complete properly. Future loads should be very fast.
  • The script writes the book details to a .cache.json file and the cover images to *.jpg files. If you’re having trouble, try deleting those files before reloading the feed.
  • I recommend disabling the “Keep Updated” option for the audiobook feed in AntennaPod. That avoids the NAS from being woken up periodically as the feed is refreshed. Just refresh the feed manually (swipe down) when you’re queuing books.

The PHP script1:

<?php
// Audiobook RSS feed generator. For more details, see
// https://kzar.co.uk/blog/2022/08/16/setting-up-a-simple-audiobook-feed-on-a-synology-nas/

// Settings.
$feedName = 'Audiobooks';
$feedDesc = 'Audiobooks feed description';
$feedURL = 'http://192.168.1.100:60808';
$coverImageFileName = 'audiobooks.png';
$ffprobePath = '/volume1/@appstore/ffmpeg/bin/ffprobe';
$ffmpegPath = '/volume1/@appstore/ffmpeg/bin/ffmpeg';


function outputRSS($books) {
  global $feedName;
  global $feedDesc;
  global $feedURL;
  global $coverImageFileName;

  setlocale(LC_CTYPE, 'en_US.UTF-8');
  header('Content-type: text/xml');

  echo '<?xml version="1.0"?>' . "\n";
  echo '<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">' . "\n";
  echo '  <channel>' . "\n";
  echo '    <title>' . htmlspecialchars($feedName) . '</title>' . "\n";
  echo '    <link>' . htmlspecialchars($feedURL) . '</link>' . "\n";
  echo '    <description>' . htmlspecialchars($feedDesc) . '</description>' . "\n";
  echo '    <image>' . "\n";
  echo '      <url>' . htmlspecialchars($feedURL . $coverImageFileName) . '</url>' . "\n";
  echo '      <title>' . htmlspecialchars($feedName) . '</title>' . "\n";
  echo '      <link>' . htmlspecialchars($feedURL) . '</link>' . "\n";
  echo '    </image>' . "\n";

  foreach($books as $book_filename => $book_details) {
    echo '  <item>' . "\n";
    echo '    <title>' . htmlspecialchars($book_details['title']) . '</title>' . "\n";
    echo '    <itunes:title>' . htmlspecialchars($book_details['title']) . '</itunes:title>' . "\n";
    echo '    <description>' . htmlspecialchars($book_details['description']) . '</description>' . "\n";
    echo '    <author>' . htmlspecialchars($book_details['author']) . '</author>' . "\n";
    echo '    <pubDate>' . htmlspecialchars($book_details['published_date']) . '</pubDate>' . "\n";
    echo '    <link>' . htmlspecialchars($book_details['url']) . '</link>' . "\n";
    echo '    <guid>' . htmlspecialchars($book_details['url']) . '</guid>' . "\n";
    echo '    <enclosure url="' . htmlspecialchars($book_details['url']) . '" ' .
                        'length="' . htmlspecialchars($book_details['size']) . '" ' .
                        'type="audio/mpeg" />' . "\n";
    if (isset($book_details['cover'])) {
      echo '    <itunes:image href="' . htmlspecialchars($book_details['cover']) . '" />' . "\n";
    }
    echo '  </item>' . "\n";
  }

  echo ' </channel>' . "\n";
  echo '</rss>' . "\n";
}

function main() {
  global $feedURL;
  global $ffprobePath;
  global $ffmpegPath;

  // Read the cache file, if it exists.
  $cache_file = './.cache.json';
  $cached_book_details = array();
  if (file_exists($cache_file)) {
    $cached_book_details = json_decode(file_get_contents($cache_file), 2);
  }

  // Loop through each book (mp3/m4b) file.
  $book_details = array();
  foreach (glob('*.{m4b,M4B,mp3,MP3}', GLOB_BRACE) as $book_file) {
    $size = filesize($book_file);
    $file_name_without_extension = pathinfo($book_file, PATHINFO_FILENAME);

    // Reuse cached book details if available and the file size hasn't changed.
    if (isset($cached_book_details[$book_file]) &&
        isset($cached_book_details[$book_file]['size']) &&
        $cached_book_details[$book_file]['size'] == $size) {
      $book_details[$book_file] = $cached_book_details[$book_file];
      continue;
    }

    // Book details aren't already cached, so figure them out now.
    $details = array();

    // Read the book's ID3 tags (if any).
    $id3_tags = json_decode(shell_exec(
      escapeshellarg($ffprobePath) . ' ' .
      '-print_format json -show_entries stream=codec_name:format ' .
      '-select_streams a:0 -v quiet ' .
      escapeshellarg($book_file)
    ), true)['format']['tags'];

    // Take note of the book's title.
    if (isset($id3_tags['title'])) {
      $details['title'] = $id3_tags['title'];
    } else {
      $details['title'] = $file_name_without_extension;
    }

    // Take note of the book's author.
    if (isset($id3_tags['artist'])) {
      $details['author'] = $id3_tags['artist'];
    } else if (isset($id3_tags['composer'])) {
      $details['author'] = $id3_tags['composer'];
    } else {
      $details['author'] = 'Unknown';
    }

    // Take note of the book's description.
    // Note: Add extra newlines so that the description is easier to read in
    //       mobile podcast apps.
    if (isset($id3_tags['description'])) {
      $details['description'] = preg_replace("/\n+/", "\n\n",
                                             $id3_tags['description']);
    } else {
      $details['description'] = '';
    }

    // Append the author and narrator details to the description.
    $details['description'] .= "\n\n" . 'Author: ' . $details['author'] . "\n";
    if (isset($id3_tags['narrated_by'])) {
      $details['description'] .= 'Narrator: ' . $id3_tags['narrated_by'];
    }

    // Fetch the cover art (if any is available) from the file if the image is
    // not yet saved.
    $cover_file = $file_name_without_extension . '.jpg';
    if (!file_exists($cover_file)) {
      shell_exec(
          escapeshellarg($ffmpegPath) . ' -loglevel quiet -i ' .
          escapeshellarg($book_file) . ' -vcodec copy ' .
          escapeshellarg($cover_file)
      );
    }
    if (file_exists($cover_file)) {
      $details['cover'] = $feedURL . rawurlencode($cover_file);
    }

    // Take note of when the book was published.
    if (isset($id3_tags['year'])) {
      $details['published_date'] = date(DATE_RSS, strtotime($id3_tags['year']));
    } else {
      $details['published_date'] = date(DATE_RSS, filemtime($book_file));
    }

    // Take note of the book's other details.
    $details['url'] = $feedURL . rawurlencode($book_file);
    $details['size'] = $size;

    // Save the book's details to the associative array.
    $book_details[$book_file] = $details;
  }

  // Write all the book details to the cache file to save work next time.
  file_put_contents($cache_file, json_encode($book_details));

  // Finally, output the RSS!
  outputRSS($book_details);
}

// Ensure the feed URL ends with a '/' and that the ffmpeg paths look correct.
// Hopefully that should make configuration less error-prone.
if (substr($feedURL, -1) != '/') {
  $feedURL .= '/';
}
if (!file_exists($ffprobePath) || !file_exists($ffmpegPath)) {
  fwrite(STDERR, 'Incorrect $ffprobePath or $ffmpegPath.' . PHP_EOL);
  exit(1);
}

main();
?>

  1. PHP script copyright 2022 Dave Vandyke, released under the MIT license↩︎