Tuesday, August 21, 2007

Roll your own podcast feed with Rails and Ruby's RSS library

I recently launched a new Rails app that powers Stoop Storytelling which was quite a labor of love. One thing I discovered was that if you take the time to design your models carefully, it's easy to add powerful capabilities later. Since The Stoop has a large trove of mp3 audio files containing all of the stories told during the series, I thought it would be fun to build a podcast. It turned out to be fairly easy although there are a few gotchas.

Shoutout

I was inspired by the incomparable Topfunky's podcast application:
http://svn.topfunky.com/podcast/

I've added code to manage the iTunes metadata that extends the original RSS standard.

Ingredients

You need to use the RSS library that comes with that standard Ruby library, but if you want to add iTunes metadata, you'll need to update the standard library like so:
cd /usr/local/src/
wget http://www.cozmixng.org/~kou/download/rss-0.1.9.tar.gz
tar zxvf rss-0.1.9.tar.gz
cd rss-0.1.9
ruby setup.rb
sudo ruby setup.rb
Then you need to require the right modules in a Rails initializer file. Mine is config/initializers/require.rb:
require 'rss/2.0'
require 'rss/itunes'
Model

Like Topfunky, I found it easier to include the bulk of the function in the model. You could also build this up using a Builder template, which might be easier to test, since you can use assert_xml_select to test it. But my philosophy was generate it in the model, test it as best I could with a unit test, then test the output completely in my controller test.


def self.rss

author = "Overall Podcast Author/Artist"

rss = RSS::Rss.new("2.0")
channel = RSS::Rss::Channel.new

category = RSS::ITunesChannelModel::ITunesCategory.new("Arts")
category.itunes_categories << \
RSS::ITunesChannelModel::ITunesCategory.new("Literature")
channel.itunes_categories << category

channel.title = "Podcast Title"
channel.description = "Podcast description, can be a paragraph"
channel.link = "http://www.example.com/"
channel.language = "en-us"
channel.copyright = "Copyright #{Date.today.year} I Own This"
channel.lastBuildDate = Audio.last_modified.updated_at
# the above uses a method I built on the Audio model that finds
# the last modified file and makes that the build date for the
# whole podcast channel

# below is your "album art"
channel.image = RSS::Rss::Channel::Image.new
channel.image.url = "http://www.example.com/images/app_rss_logo.jpg"
channel.image.title = "Same as podcast title"
channel.image.link = "Should be same as link for whole channel"

channel.itunes_author = author
channel.itunes_owner = RSS::ITunesChannelModel::ITunesOwner.new
channel.itunes_owner.itunes_name=author
channel.itunes_owner.itunes_email='info@example.com'

channel.itunes_keywords = %w(Common Misspellings of Key Words)

channel.itunes_subtitle = "This appears in the description column of iTunes"
channel.itunes_summary = "This appears when you click the 'circle I' button in iTunes"

# below is what iTunes uses for your "album art", different from RSS standard
channel.itunes_image = RSS::ITunesChannelModel::ITunesImage.new("/path/to/logo.png")
channel.itunes_explicit = "No"
# above could also be "Yes" or "Clean"

Audio.find(:all).each do |audio|
item = RSS::Rss::Channel::Item.new
item.title = audio.title
link = "http://www.example.com/#{audio.public_filename}"
item.link = link
item.itunes_keywords = %w(Keywords For This Particular Audio Clip)
item.guid = RSS::Rss::Channel::Item::Guid.new
item.guid.content = link
item.guid.isPermaLink = true
item.pubDate = audio.updated_at

description = "Long description of this particular audio file, appears in circle I section of
iTunes"

item.description = description
item.itunes_summary = description
item.itunes_subtitle = audio.nice_title
item.itunes_explicit = "No"
item.itunes_author = author

# TODO can add duration once we can compute that somehow

item.enclosure = \
RSS::Rss::Channel::Item::Enclosure.new(item.link, audio.size, 'audio/mpeg')
channel.items << item

end

rss.channel = channel
return rss.to_s

end


Easy, right? What you're doing is building up an RSS feed that complies with the RSS 2.0 standard and with Apple's iTunes extension to the base standard. The category code is a little tricky and there may be a better way to do it. I found the RSS library docs a bit hard to understand.

Controller

All of the heavy lifting is done for us, so the controller is easy.

class AudioController < ApplicationController

def index
respond_to do |format|
format.html { @audio = Audio.find(:all) }
format.xml { render :xml => Audio.rss }
end
end

end

Route

Because of my inflection rules (where audio is singular and plural) I can't use the baked-in REST route to handle this situation, so for this to work I needed to manually wire the following route:

map.formatted_audio 'audio.:format', :controller => 'audio', :action => 'index'

Unit Test

I haven't written enough tests for this, but here's a start so I at least know the right number of audio files are in there. In audio_test.rb:

def test_should_generate_audio_rss
assert_equal Audio.count, Audio.rss.scan(//).size
end

Functional Test

As mentioned earlier, testing the method here gives you the added benefit of assert_select, but you need to apply Jamis Buck's wizardry to get it to work with XML files by creating an assert_xml_select method.

I'm testing almost the exact same thing as the unit test, but at least I know the file gets served up the way I expect it, and now I'm also testing whether the items have been nested properly within the channel.

def test_should_get_audio_rss
get :index, :format => 'xml'
assert_response :success
assert_xml_select 'channel item', :count => Audio.count
end


I want to improve this by incorporating XML validation into my tests but haven't figured that out yet. For now, I'm validating the feed itself.

Feed Validation

The last step before submitting to iTunes is to make sure this feed works and is valid XML. I used FeedValidator.

iTunes Submission

Pretty straightforward. Just follow Apple's directions. Getting the category right was a little tough, and there's also an issue with iTunes not recognizing the feed image properly. Doesn't look like they've fixed it yet.

Submitting Elsewhere

I added the following to the head section of my app's layout to help other aggregators find the feed:


%link{:rel=>"alternate", :type=>"application/rss+xml", :title=>"Stoop Storytelling Podcast", :href => formatted_audio_path(:format => 'xml', :only_path=>false)}


This reference was helpful in constructing the above tag.

Advertising the Link

Finally, I posted links to the iTunes store on the site itself. If people have iTunes or another podcast client installed, clicking on the store link will cause them to subscribe and help boost the rating of the podcast (thus, you don't want to give people the link to the audio.xml file itself so direct subscriptions won't get counted by Apple).

Final Product

You can listen to the podcasts here:
http://www.itunes.com/podcast?id=262444919

References

Beyond the references already cited, I relied heavily on the RSS library tutorial and reference.

Future

Is there interest in a plugin to help streamline this process further?

7 comments:

Sam said...

Dude, you're a life saver with the download link!

Mike said...

thanks! That was probably the hardest part, realizing that there was a newer version of the standard libraries and then finding it.

kou said...

You can use RSS Maker:

require 'rss'

author = "Overall Podcast Author/Artist"

rss = RSS::Maker.make("2.0") do |maker|
channel = maker.channel
channel.title = "Podcast Title"
channel.description = "Podcast description, can be a paragraph"
channel.link = "http://www.example.com/"
channel.language = "en-us"
channel.copyright = "Copyright #{Date.today.year} I Own This"
channel.lastBuildDate = Audio.last_modified.updated_at

image = maker.image
image.url = "http://www.example.com/images/app_rss_logo.jpg"
image.title = "Same as podcast title"

channel.itunes_author = author
channel.itunes_owner.itunes_name = author
channel.itunes_owner.itunes_email='info@example.com'

channel.itunes_keywords = %w(Common Misspellings of Key Words)

channel.itunes_subtitle = "This appears in the description column of iTunes"
channel.itunes_summary = "This appears when you click the 'circle I' button in iTunes"

# below is what iTunes uses for your "album art", different from RSS standard
channel.itunes_image = "/path/to/logo.png"
channel.itunes_explicit = "No"

category = channel.itunes_categories.new_itunes_category
category.text = "Arts"
category.new_itunes_category.text = "Literature"

Audio.find(:all).each do |audio|
maker.items.new_item do |item|
item.title = audio.title
link = "http://www.example.com/#{audio.public_filename}"
item.link = link
item.itunes_keywords = %w(Keywords For This Particular Audio Clip)
item.guid.content = link
item.guid.isPermaLink = true
item.pubDate = audio.updated_at

description = "Long description of this particular audio file, appears in circle I section of iTunes"
item.description = description
item.itunes_summary = description
item.itunes_subtitle = audio.nice_title
item.itunes_explicit = "No"
item.itunes_author = author

# TODO can add duration once we can compute that somehow
item.enclosure.url = item.link
item.enclosure.length = audio.size
item.enclosure.type = 'audio/mpeg'
end
end
end

kou said...

Sorry. new_itunes_category should be new_category.

bparanj said...

Why do we need this when we have feedburner? It provides tools to create iTunes RSS feeds.

jonathan said...

Nice work with this. To answer your question about the plugin, yes, that would be nice.

Charles said...

I would love a plugin for this.