dropcaster 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.document +5 -5
- data/Gemfile +11 -11
- data/Gemfile.lock +26 -26
- data/LICENSE.txt +20 -20
- data/README.md +155 -147
- data/Rakefile +47 -47
- data/TODO +14 -13
- data/VERSION +1 -1
- data/bin/dropcaster +117 -111
- data/bin/lstags +45 -45
- data/doc/sample-channel.yml +79 -77
- data/doc/sample-sidecar.yml +19 -19
- data/dropcaster.gemspec +88 -87
- data/lib/dropcaster/channel.rb +96 -86
- data/lib/dropcaster/channel_file_locator.rb +48 -48
- data/lib/dropcaster/errors.rb +25 -19
- data/lib/dropcaster/hashkeys.rb +12 -12
- data/lib/dropcaster/item.rb +43 -43
- data/lib/dropcaster.rb +18 -18
- data/templates/{channel.rss.erb → iTunes.rss.erb} +57 -57
- data/test/extensions/windows.rb +12 -0
- data/test/fixtures/channel.yml +13 -14
- data/test/helper.rb +14 -10
- data/test/unit/test_app.rb +84 -59
- data/test/unit/test_channel.rb +32 -32
- data/test/unit/test_channel_locator.rb +91 -91
- data/test/unit/test_channel_xml.rb +87 -69
- data/test/unit/test_item.rb +63 -61
- metadata +24 -19
data/bin/dropcaster
CHANGED
@@ -1,111 +1,117 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
|
4
|
-
|
5
|
-
require 'rubygems'
|
6
|
-
require 'yaml'
|
7
|
-
|
8
|
-
help = <<HELP
|
9
|
-
Dropcaster is a podcast feed generator for the command line.
|
10
|
-
|
11
|
-
Author: Nicolas E. Rabenau nerab@gmx.at
|
12
|
-
Homepage: http://nerab.github.com/dropcaster/
|
13
|
-
|
14
|
-
Basic Usage:
|
15
|
-
|
16
|
-
dropcaster Prints a podcast feed document for the mp3 files in the current directory.
|
17
|
-
dropcaster [FILE]... Prints a podcast feed document for FILES
|
18
|
-
dropcaster [DIR]... Prints a podcast feed document for the mp3 files in DIR
|
19
|
-
|
20
|
-
Options:
|
21
|
-
|
22
|
-
HELP
|
23
|
-
|
24
|
-
def usage
|
25
|
-
"Run '#{File.basename(__FILE__)} --help' for further help."
|
26
|
-
end
|
27
|
-
|
28
|
-
require 'optparse'
|
29
|
-
require 'dropcaster'
|
30
|
-
|
31
|
-
options = Hash.new
|
32
|
-
options[:verbose] = false
|
33
|
-
options[:auto_detect_channel_file] = true
|
34
|
-
|
35
|
-
opts = OptionParser.new do |opts|
|
36
|
-
opts.banner = help
|
37
|
-
|
38
|
-
opts.on("--verbose", "Verbose mode - displays additional diagnostic information") do |file|
|
39
|
-
options[:verbose] = true
|
40
|
-
end
|
41
|
-
|
42
|
-
opts.on("--channel FILE", "Read the channel definition from FILE instead of channel.yml in the current directory.") do |file|
|
43
|
-
begin
|
44
|
-
STDERR.puts "Reading channel definition from #{file}" if options[:verbose]
|
45
|
-
options = YAML.load_file(file).merge(options)
|
46
|
-
options[:auto_detect_channel_file] = false
|
47
|
-
rescue
|
48
|
-
STDERR.puts "Error loading channel definition: #{$!.message}"
|
49
|
-
STDERR.puts $!.backtrace if options[:verbose]
|
50
|
-
exit(1)
|
51
|
-
end
|
52
|
-
end
|
53
|
-
|
54
|
-
opts.on("--title STRING", "Use STRING as the channel's title. Overrides settings read from channel definition file.") do |title|
|
55
|
-
STDERR.puts "Setting channel title to '#{title}' via command line" if options[:verbose]
|
56
|
-
options[:title] = title
|
57
|
-
end
|
58
|
-
|
59
|
-
opts.on("--url URL", "Use URL as the channel's url. Overrides settings read from channel definition file.") do |url|
|
60
|
-
STDERR.puts "Setting channel URL to '#{url}' via command line" if options[:verbose]
|
61
|
-
options[:url] = url
|
62
|
-
end
|
63
|
-
|
64
|
-
opts.on("--description STRING", "Use STRING as the channel's description. Overrides settings read from channel definition file.") do |description|
|
65
|
-
STDERR.puts "Setting channel description to '#{description}' via command line" if options[:verbose]
|
66
|
-
options[:description] = description
|
67
|
-
end
|
68
|
-
|
69
|
-
opts.on("--
|
70
|
-
STDERR.puts "Setting
|
71
|
-
options[:
|
72
|
-
end
|
73
|
-
|
74
|
-
opts.on("--
|
75
|
-
options[:
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
end
|
83
|
-
|
84
|
-
opts.
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
STDERR.puts "
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
|
4
|
+
|
5
|
+
require 'rubygems'
|
6
|
+
require 'yaml'
|
7
|
+
|
8
|
+
help = <<HELP
|
9
|
+
Dropcaster is a podcast feed generator for the command line.
|
10
|
+
|
11
|
+
Author: Nicolas E. Rabenau nerab@gmx.at
|
12
|
+
Homepage: http://nerab.github.com/dropcaster/
|
13
|
+
|
14
|
+
Basic Usage:
|
15
|
+
|
16
|
+
dropcaster Prints a podcast feed document for the mp3 files in the current directory.
|
17
|
+
dropcaster [FILE]... Prints a podcast feed document for FILES
|
18
|
+
dropcaster [DIR]... Prints a podcast feed document for the mp3 files in DIR
|
19
|
+
|
20
|
+
Options:
|
21
|
+
|
22
|
+
HELP
|
23
|
+
|
24
|
+
def usage
|
25
|
+
"Run '#{File.basename(__FILE__)} --help' for further help."
|
26
|
+
end
|
27
|
+
|
28
|
+
require 'optparse'
|
29
|
+
require 'dropcaster'
|
30
|
+
|
31
|
+
options = Hash.new
|
32
|
+
options[:verbose] = false
|
33
|
+
options[:auto_detect_channel_file] = true
|
34
|
+
|
35
|
+
opts = OptionParser.new do |opts|
|
36
|
+
opts.banner = help
|
37
|
+
|
38
|
+
opts.on("--verbose", "Verbose mode - displays additional diagnostic information") do |file|
|
39
|
+
options[:verbose] = true
|
40
|
+
end
|
41
|
+
|
42
|
+
opts.on("--channel FILE", "Read the channel definition from FILE instead of channel.yml in the current directory.") do |file|
|
43
|
+
begin
|
44
|
+
STDERR.puts "Reading channel definition from #{file}" if options[:verbose]
|
45
|
+
options = YAML.load_file(file).merge(options)
|
46
|
+
options[:auto_detect_channel_file] = false
|
47
|
+
rescue
|
48
|
+
STDERR.puts "Error loading channel definition: #{$!.message}"
|
49
|
+
STDERR.puts $!.backtrace if options[:verbose]
|
50
|
+
exit(1)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
opts.on("--title STRING", "Use STRING as the channel's title. Overrides settings read from channel definition file.") do |title|
|
55
|
+
STDERR.puts "Setting channel title to '#{title}' via command line" if options[:verbose]
|
56
|
+
options[:title] = title
|
57
|
+
end
|
58
|
+
|
59
|
+
opts.on("--url URL", "Use URL as the channel's url. Overrides settings read from channel definition file.") do |url|
|
60
|
+
STDERR.puts "Setting channel URL to '#{url}' via command line" if options[:verbose]
|
61
|
+
options[:url] = url
|
62
|
+
end
|
63
|
+
|
64
|
+
opts.on("--description STRING", "Use STRING as the channel's description. Overrides settings read from channel definition file.") do |description|
|
65
|
+
STDERR.puts "Setting channel description to '#{description}' via command line" if options[:verbose]
|
66
|
+
options[:description] = description
|
67
|
+
end
|
68
|
+
|
69
|
+
opts.on("--enclosures URL", "Use URL as the base URL for the channel's enclosures. Overrides settings read from channel definition file.") do |enclosures_url|
|
70
|
+
STDERR.puts "Setting enclosures base URL to '#{enclosures_url}' via command line" if options[:verbose]
|
71
|
+
options[:enclosures_url] = enclosures_url
|
72
|
+
end
|
73
|
+
|
74
|
+
opts.on("--image URL", "Use URL as the channel's image URL. Overrides settings read from channel definition file.") do |image_url|
|
75
|
+
STDERR.puts "Setting image URL to '#{image_url}' via command line" if options[:verbose]
|
76
|
+
options[:image_url] = image_url
|
77
|
+
end
|
78
|
+
|
79
|
+
opts.on("--channel-template FILE", "Use FILE as template for generating the channel feed. Overrides the default that comes with Dropcaster.") do |file|
|
80
|
+
STDERR.puts "Using'#{file}' as channel template file" if options[:verbose]
|
81
|
+
options[:channel_template] = file
|
82
|
+
end
|
83
|
+
|
84
|
+
opts.on("--version", "Display current version") do
|
85
|
+
puts "#{File.basename(__FILE__)} " + Dropcaster::VERSION
|
86
|
+
exit 0
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
opts.parse!
|
91
|
+
sources = ARGV.blank? ? '.' : ARGV
|
92
|
+
|
93
|
+
if options[:auto_detect_channel_file]
|
94
|
+
# There was no channel file specified, so we try to load channel.yml from sources dir
|
95
|
+
channel_file = Dropcaster::ChannelFileLocator.locate(sources)
|
96
|
+
|
97
|
+
if File.exists?(channel_file)
|
98
|
+
STDERR.puts "Auto-detected channel file at #{channel_file}" if options[:verbose]
|
99
|
+
options_from_yaml = YAML.load_file(channel_file)
|
100
|
+
options = options_from_yaml.merge(options)
|
101
|
+
else
|
102
|
+
STDERR.puts "No #{channel_file} found."
|
103
|
+
STDERR.puts usage
|
104
|
+
exit(1) # No way to continue without a channel definition
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
STDERR.puts "Generating the channel with these options: #{options.inspect}" if options[:verbose]
|
109
|
+
|
110
|
+
begin
|
111
|
+
puts Dropcaster::Channel.new(sources, options).to_rss
|
112
|
+
rescue
|
113
|
+
STDERR.puts $!.message
|
114
|
+
STDERR.puts usage
|
115
|
+
STDERR.puts $!.backtrace if options[:verbose]
|
116
|
+
exit(1)
|
117
|
+
end
|
data/bin/lstags
CHANGED
@@ -1,45 +1,45 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
unless ARGV.size == 1
|
4
|
-
STDERR.puts "#{File.basename(__FILE__)}: Missing required parameter for the mp3 file to process"
|
5
|
-
exit(1)
|
6
|
-
end
|
7
|
-
|
8
|
-
require 'rubygems'
|
9
|
-
require 'mp3info'
|
10
|
-
|
11
|
-
begin
|
12
|
-
Mp3Info.open(ARGV.first) do |mp3info|
|
13
|
-
puts 'ID3v1 tags:'
|
14
|
-
mp3info.tag.keys.each{|key|
|
15
|
-
puts " #{key} => #{mp3info.tag.send(key)}"
|
16
|
-
}
|
17
|
-
puts
|
18
|
-
puts 'ID3v2 tags:'
|
19
|
-
mp3info.tag2.keys.each{|key|
|
20
|
-
case key
|
21
|
-
when 'PIC'
|
22
|
-
when 'APIC'
|
23
|
-
# picture - do not print binary data
|
24
|
-
when 'ULT'
|
25
|
-
print " ULT => "
|
26
|
-
block_counter = 0
|
27
|
-
mp3info.tag2.ULT.bytes{|b|
|
28
|
-
print "0x%02x " % b.to_i
|
29
|
-
print b > 31 ? " '#{b.chr}' " : " " * 5
|
30
|
-
if (block_counter += 1) > 7 # display in blocks of 8 bytes
|
31
|
-
puts
|
32
|
-
print " " * 9
|
33
|
-
block_counter = 0
|
34
|
-
end
|
35
|
-
}
|
36
|
-
puts
|
37
|
-
else
|
38
|
-
puts " #{key} => #{mp3info.tag2.send(key)}"
|
39
|
-
end
|
40
|
-
}
|
41
|
-
end
|
42
|
-
rescue
|
43
|
-
puts "Error: #{$!.message}"
|
44
|
-
exit(1)
|
45
|
-
end
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
unless ARGV.size == 1
|
4
|
+
STDERR.puts "#{File.basename(__FILE__)}: Missing required parameter for the mp3 file to process"
|
5
|
+
exit(1)
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'rubygems'
|
9
|
+
require 'mp3info'
|
10
|
+
|
11
|
+
begin
|
12
|
+
Mp3Info.open(ARGV.first) do |mp3info|
|
13
|
+
puts 'ID3v1 tags:'
|
14
|
+
mp3info.tag.keys.each{|key|
|
15
|
+
puts " #{key} => #{mp3info.tag.send(key)}"
|
16
|
+
}
|
17
|
+
puts
|
18
|
+
puts 'ID3v2 tags:'
|
19
|
+
mp3info.tag2.keys.each{|key|
|
20
|
+
case key
|
21
|
+
when 'PIC'
|
22
|
+
when 'APIC'
|
23
|
+
# picture - do not print binary data
|
24
|
+
when 'ULT'
|
25
|
+
print " ULT => "
|
26
|
+
block_counter = 0
|
27
|
+
mp3info.tag2.ULT.bytes{|b|
|
28
|
+
print "0x%02x " % b.to_i
|
29
|
+
print b > 31 ? " '#{b.chr}' " : " " * 5
|
30
|
+
if (block_counter += 1) > 7 # display in blocks of 8 bytes
|
31
|
+
puts
|
32
|
+
print " " * 9
|
33
|
+
block_counter = 0
|
34
|
+
end
|
35
|
+
}
|
36
|
+
puts
|
37
|
+
else
|
38
|
+
puts " #{key} => #{mp3info.tag2.send(key)}"
|
39
|
+
end
|
40
|
+
}
|
41
|
+
end
|
42
|
+
rescue
|
43
|
+
puts "Error: #{$!.message}"
|
44
|
+
exit(1)
|
45
|
+
end
|
data/doc/sample-channel.yml
CHANGED
@@ -1,77 +1,79 @@
|
|
1
|
-
#
|
2
|
-
# A sample RSS channel definition
|
3
|
-
#
|
4
|
-
# This file is read by Dropcaster
|
5
|
-
# http://github.com/nerab/dropcaster
|
6
|
-
#
|
7
|
-
# It defines the properties of your podcast channel.
|
8
|
-
#
|
9
|
-
|
10
|
-
#
|
11
|
-
# Title (name) of the podcast
|
12
|
-
#
|
13
|
-
:title: 'All About Everything'
|
14
|
-
|
15
|
-
#
|
16
|
-
# Short description of the podcast (a few words)
|
17
|
-
#
|
18
|
-
:subtitle: 'A show about everything'
|
19
|
-
|
20
|
-
#
|
21
|
-
# URL to the podcast.
|
22
|
-
#
|
23
|
-
:url: 'http://www.example.com/podcasts/everything/
|
24
|
-
|
25
|
-
#
|
26
|
-
#
|
27
|
-
#
|
28
|
-
:
|
29
|
-
|
30
|
-
#
|
31
|
-
# Language of the podcast - ISO 639-1 Alpha-2 list (two-letter language codes, some with possible modifiers, such as "en-us").
|
32
|
-
#
|
33
|
-
:language: 'en-us'
|
34
|
-
|
35
|
-
#
|
36
|
-
# Not visible in iTunes, but useful as a statement in the feed
|
37
|
-
#
|
38
|
-
:copyright: '© 2011 John Doe & Family'
|
39
|
-
|
40
|
-
#
|
41
|
-
# Author / creator of the podcast. In iTunes, it is displayed in the artist column.
|
42
|
-
#
|
43
|
-
:author: 'John Doe'
|
44
|
-
|
45
|
-
#
|
46
|
-
# Longer description of the podcast
|
47
|
-
#
|
48
|
-
:description: 'All About Everything is a show about everything. Each week we dive into any subject known to man and talk about it as much as we can. Look for our Podcast in the iTunes Store!'
|
49
|
-
|
50
|
-
#
|
51
|
-
# Contact information of the owner of the podcast. Not be publicly displayed in iTunes.
|
52
|
-
#
|
53
|
-
:owner:
|
54
|
-
:name: 'John Doe'
|
55
|
-
:email: 'john.doe@example.com'
|
56
|
-
|
57
|
-
#
|
58
|
-
# iTunes prefers square .jpg images that are at least 600 x 600 pixels
|
59
|
-
#
|
60
|
-
:
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
#
|
65
|
-
#
|
66
|
-
#
|
67
|
-
#
|
68
|
-
#
|
69
|
-
#
|
70
|
-
# :categories:
|
71
|
-
|
72
|
-
:categories: ['Technology', 'Gadgets']
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
#
|
77
|
-
|
1
|
+
#
|
2
|
+
# A sample RSS channel definition
|
3
|
+
#
|
4
|
+
# This file is read by Dropcaster
|
5
|
+
# http://github.com/nerab/dropcaster
|
6
|
+
#
|
7
|
+
# It defines the properties of your podcast channel.
|
8
|
+
#
|
9
|
+
|
10
|
+
#
|
11
|
+
# Title (name) of the podcast
|
12
|
+
#
|
13
|
+
:title: 'All About Everything'
|
14
|
+
|
15
|
+
#
|
16
|
+
# Short description of the podcast (a few words)
|
17
|
+
#
|
18
|
+
:subtitle: 'A show about everything'
|
19
|
+
|
20
|
+
#
|
21
|
+
# URL to the podcast.
|
22
|
+
#
|
23
|
+
:url: 'http://www.example.com/podcasts/everything/'
|
24
|
+
|
25
|
+
#
|
26
|
+
# Optional base URL for enclosure links
|
27
|
+
#
|
28
|
+
:enclosures_url: 'http://www.example.com/podcasts/everything/episodes'
|
29
|
+
|
30
|
+
#
|
31
|
+
# Language of the podcast - ISO 639-1 Alpha-2 list (two-letter language codes, some with possible modifiers, such as "en-us").
|
32
|
+
#
|
33
|
+
:language: 'en-us'
|
34
|
+
|
35
|
+
#
|
36
|
+
# Not visible in iTunes, but useful as a statement in the feed
|
37
|
+
#
|
38
|
+
:copyright: '© 2011 John Doe & Family'
|
39
|
+
|
40
|
+
#
|
41
|
+
# Author / creator of the podcast. In iTunes, it is displayed in the artist column.
|
42
|
+
#
|
43
|
+
:author: 'John Doe'
|
44
|
+
|
45
|
+
#
|
46
|
+
# Longer description of the podcast
|
47
|
+
#
|
48
|
+
:description: 'All About Everything is a show about everything. Each week we dive into any subject known to man and talk about it as much as we can. Look for our Podcast in the iTunes Store!'
|
49
|
+
|
50
|
+
#
|
51
|
+
# Contact information of the owner of the podcast. Not be publicly displayed in iTunes.
|
52
|
+
#
|
53
|
+
:owner:
|
54
|
+
:name: 'John Doe'
|
55
|
+
:email: 'john.doe@example.com'
|
56
|
+
|
57
|
+
#
|
58
|
+
# iTunes prefers square .jpg images that are at least 600 x 600 pixels
|
59
|
+
#
|
60
|
+
# If the URL does not start with http: or https:, it will be prefixed with the channel url.
|
61
|
+
#
|
62
|
+
:image_url: 'AllAboutEverything.jpg'
|
63
|
+
|
64
|
+
#
|
65
|
+
# Category / categories of the podcast
|
66
|
+
#
|
67
|
+
# For iTunes, see http://www.apple.com/itunes/podcasts/specs.html#categories for applicable values
|
68
|
+
#
|
69
|
+
# Examples:
|
70
|
+
# :categories: 'Technology'
|
71
|
+
# :categories: ['Technology', 'Gadgets']
|
72
|
+
# :categories: ['TV & Film', ['Technology', 'Gadgets']]
|
73
|
+
|
74
|
+
:categories: ['Technology', 'Gadgets']
|
75
|
+
|
76
|
+
#
|
77
|
+
# Yes, No or Clean, see http://www.apple.com/itunes/podcasts/specs.html#explicit
|
78
|
+
#
|
79
|
+
:explicit: 'No'
|
data/doc/sample-sidecar.yml
CHANGED
@@ -1,19 +1,19 @@
|
|
1
|
-
#
|
2
|
-
# Sample sidecar file
|
3
|
-
#
|
4
|
-
# If this file is present side-by-side with an mp3 file, and if it has the same name as the mp3 (but with
|
5
|
-
# an extension of yml or yaml), then any setting made in this file will be used instead of the value from the mp3.
|
6
|
-
#
|
7
|
-
:title: 'Title of the episode'
|
8
|
-
:author: 'Author of the episode'
|
9
|
-
:subtitle: 'Subtitle of the episode'
|
10
|
-
:description: 'Desciption and summary of the episode'
|
11
|
-
:image_url: 'URL to an image specific for this episode'
|
12
|
-
:enclosure:
|
13
|
-
:url: 'URL where this episode can be downloaded'
|
14
|
-
:length: 42 # length of the episode in milliseconds
|
15
|
-
:type: 'usually audio/mp3'
|
16
|
-
:guid: 'Globally unique id of the episode'
|
17
|
-
:pubDate: 'Date as per RFC 2822; e.g. Wed, 15 Jun 2005 19:00:00 GMT'
|
18
|
-
:duration: 'Either HH:MM:SS, H:MM:SS, MM:SS, M:SS, or SS'
|
19
|
-
:keywords: ['ruby', 'rails', 'podcast'] # up to 12 text keywords
|
1
|
+
#
|
2
|
+
# Sample sidecar file
|
3
|
+
#
|
4
|
+
# If this file is present side-by-side with an mp3 file, and if it has the same name as the mp3 (but with
|
5
|
+
# an extension of yml or yaml), then any setting made in this file will be used instead of the value from the mp3.
|
6
|
+
#
|
7
|
+
:title: 'Title of the episode'
|
8
|
+
:author: 'Author of the episode'
|
9
|
+
:subtitle: 'Subtitle of the episode'
|
10
|
+
:description: 'Desciption and summary of the episode'
|
11
|
+
:image_url: 'URL to an image specific for this episode'
|
12
|
+
:enclosure:
|
13
|
+
:url: 'URL where this episode can be downloaded'
|
14
|
+
:length: 42 # length of the episode in milliseconds
|
15
|
+
:type: 'usually audio/mp3'
|
16
|
+
:guid: 'Globally unique id of the episode'
|
17
|
+
:pubDate: 'Date as per RFC 2822; e.g. Wed, 15 Jun 2005 19:00:00 GMT'
|
18
|
+
:duration: 'Either HH:MM:SS, H:MM:SS, MM:SS, M:SS, or SS'
|
19
|
+
:keywords: ['ruby', 'rails', 'podcast'] # up to 12 text keywords
|