jekyll-octopod 0.21.0 → 0.23.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08b3d44f4b48f6f493121d5d6bcd4708d4d82f54a386be9a79e8f4cf9df5aceb'
4
- data.tar.gz: 518582535ee414dcc81f41ad540bf3242bd2fe339067d292733c7c3abe995ca9
3
+ metadata.gz: 2e8a41ff862f72c1aec7028926e077d7bd3d6f202a3139b2e733d1ea2033b2df
4
+ data.tar.gz: 3a9f0249e0039acc36666d3e3c74d450864677b73967b63ff8f79e75a7cf90d3
5
5
  SHA512:
6
- metadata.gz: df52f5ac952f8ce2f4ae7089696183f519f93d0f43133e02011e1f66114c8b9b2e57b32d6da857b69439330af566b53693495f5c7ab733eee8a8e1e7e0d2c256
7
- data.tar.gz: 61da23c9b801a3f33e011e97723c0dcb2a4d5de86cd15b0e3bdeb8f4cb3f5527eb93c1fc600aaabb6b7e24181b96a223b277c05cc06bc48422a07dc4551b4c47
6
+ metadata.gz: ff581e079d12768deded21853f2568ce4a1fbe03c42187e81f1cf8016979f2fe9f3deaed583cf93ec436f8f9f7cfb20e00b90ee1d20f98700836d35eb3120140
7
+ data.tar.gz: 62c687c7c7b91ff57ea896b6145bb7e8b16c786d358ae95ac1c3095dc96db89e8a5fbe9d012498e74162215c97d429c3abf908e02e1aef2e36d3903425761508
data/bin/octopod CHANGED
@@ -28,6 +28,10 @@ Basic Command Line Usage:
28
28
 
29
29
  Additional Octopod commands:
30
30
  octopod episode # adds a template for a new episode
31
+ octopod import <feed url> [target directory] # creates a new site from an existing podcast
32
+ # feed, one post per episode, downloading all
33
+ # enclosures, images and transcripts locally
34
+ # (--no-download: link to the audio instead)
31
35
  octopod deploy # deploys your site
32
36
  octopod update # updates template files from an existing setup to
33
37
  # the currently installed jekyll-octopod version,
@@ -62,6 +66,21 @@ using the following options:
62
66
 
63
67
  DEPLOY_HELP
64
68
 
69
+ import_help = <<IMPORT_HELP
70
+ octopod import - creates a new podcast site from an existing podcast RSS feed
71
+
72
+ octopod import <feed url or file> [target directory] [options]
73
+
74
+ Channel metadata goes into _config.yml, every episode becomes a post in _posts/,
75
+ and all enclosures, episode images and WebVTT transcripts are downloaded into the
76
+ new site, so it hosts everything itself (use --no-download to keep the audio
77
+ where it is and only link to it). Paged feeds (atom:link rel="next") are
78
+ followed. The target directory defaults to a slug of the podcast's title.
79
+
80
+ (Any other 'octopod import <importer name>' is passed on to Jekyll's own importers.)
81
+
82
+ IMPORT_HELP
83
+
65
84
  DEFAULT_EXT = '.md'
66
85
 
67
86
  class OptionParser
@@ -215,7 +234,47 @@ def get_stdin(message)
215
234
  STDIN.gets.chomp
216
235
  end
217
236
 
218
- if ARGV.size > 0 && (ARGV[0] == 'episode' || ARGV[0] == 'deploy' || ARGV[0] == 'setup' || ARGV[0] == 'update')
237
+ if ARGV[0] == 'import' && (ARGV.include?('--help') || ARGV.include?('-h') ||
238
+ (ARGV[1] && (ARGV[1].match?(%r{\Ahttps?://}i) || File.file?(ARGV[1]))))
239
+ require 'octopod/importer'
240
+
241
+ import_options = {}
242
+ opts = OptionParser.new do |opts|
243
+ opts._octopod_banner = import_help
244
+
245
+ opts.on("--url [URL]", "Public url of the new site, written to _config.yml (default: http://localhost:4000)") do |url|
246
+ import_options[:site_url] = url
247
+ end
248
+
249
+ opts.on("--limit [N]", Integer, "Only import the newest N episodes, e.g. for a quick trial run") do |limit|
250
+ import_options[:limit] = limit
251
+ end
252
+
253
+ opts.on("--no-download", "Don't download the enclosures, link to them where they are: sets download_url",
254
+ "in _config.yml and filesize in each post (images and transcripts are still",
255
+ "downloaded)") do
256
+ import_options[:download_enclosures] = false
257
+ end
258
+
259
+ opts.on("-f", "--force", "Import into an existing, non-empty directory - e.g. to resume an interrupted",
260
+ "import: already downloaded files are kept, everything else is rewritten") do
261
+ import_options[:force] = true
262
+ end
263
+ end
264
+ opts.parse!
265
+
266
+ if ARGV[1].nil?
267
+ puts opts.help
268
+ exit
269
+ end
270
+
271
+ target = ARGV[2] && File.expand_path(ARGV[2], PWD)
272
+ begin
273
+ Jekyll::Octopod::Importer.new(ARGV[1], target, gem_spec: GEM_SPEC, **import_options).run
274
+ rescue ArgumentError, SystemCallError, REXML::ParseException => e
275
+ abort "octopod import failed: #{e.message}"
276
+ end
277
+ elsif ARGV.size > 0 && (ARGV[0] == 'episode' || ARGV[0] == 'deploy' || ARGV[0] == 'setup' || ARGV[0] == 'update')
219
278
  options = YAML.load_file(File.join(PWD, '_config.yml'))
220
279
  options['posts_dir'] = File.join(PWD, '_posts')
221
280
 
@@ -4,6 +4,11 @@ module Jekyll
4
4
  class PodlovePlayerTag < Liquid::Tag
5
5
  include Jekyll::OctopodFilters
6
6
 
7
+ # Every channel the player itself supports (per the config docs below) - sent unconditionally
8
+ # rather than exposed as a site/post setting, so the share tab always has something to offer
9
+ # instead of defaulting to the player's own empty channel list.
10
+ SHARE_CHANNELS = %w[twitter facebook whats-app linkedin pinterest xing mail link].freeze
11
+
7
12
  # From here: https://docs.podlove.org/podlove-web-player/v5/configuration
8
13
  def playerconfig(context)
9
14
  config = context.registers[:site].config
@@ -40,7 +45,8 @@ module Jekyll
40
45
  duration: page["duration"],
41
46
  audio: audio,
42
47
  chapters: page["chapters"] ? page["chapters"].map { |chapter| split_chapter(chapter) }.compact : nil,
43
- transcripts: transcripts_for(page)
48
+ transcripts: transcripts_for(page),
49
+ share: { channels: SHARE_CHANNELS }
44
50
  }.to_json
45
51
  end
46
52
 
@@ -0,0 +1,659 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'rexml/document'
5
+ require 'time'
6
+ require 'uri'
7
+ require 'yaml'
8
+ require 'octopod/version'
9
+
10
+ module Jekyll
11
+ class Octopod
12
+ # Creates a brand new jekyll-octopod site from an existing podcast RSS feed: channel metadata
13
+ # goes into _config.yml, every <item> becomes one post in _posts/, and every enclosure (plus
14
+ # episode images and WebVTT transcripts, where the feed has them) is downloaded next to it, so
15
+ # the new site hosts everything itself - no 'download_url' or 'filesize' front matter needed,
16
+ # the same setup 'octopod setup' ships for a fresh site.
17
+ #
18
+ # With download_enclosures: false, only the audio stays where it is: _config.yml's
19
+ # 'download_url' is set to the directory all enclosure URLs share, each post's 'audio' holds
20
+ # the rest of its URL, and 'filesize' comes from the feed (or a HEAD request) - the same setup
21
+ # as a site that hosts its audio remotely by hand.
22
+ #
23
+ # Used by 'octopod import <feed url> [target directory]'. Network access goes through a
24
+ # fetcher object (HttpFetcher by default) so specs can swap in a fake one.
25
+ class Importer
26
+ GEM_ROOT = File.expand_path('../..', __dir__)
27
+
28
+ # Namespace URIs by the prefix they're conventionally used with. Matched by URI, never by
29
+ # prefix, since feeds are free to bind any prefix they like. iTunes has been seen in the
30
+ # wild with both spellings of its DTD path.
31
+ NAMESPACES = {
32
+ 'rss' => [nil, ''],
33
+ 'itunes' => ['http://www.itunes.com/dtds/podcast-1.0.dtd', 'http://www.itunes.com/DTDs/Podcast-1.0.dtd'],
34
+ 'content' => ['http://purl.org/rss/1.0/modules/content/'],
35
+ 'atom' => ['http://www.w3.org/2005/Atom'],
36
+ 'psc' => ['http://podlove.org/simple-chapters'],
37
+ 'podcast' => ['https://podcastindex.org/namespace/1.0']
38
+ }.freeze
39
+
40
+ # Enclosure MIME types mapped to the format keys OctopodFilters#mime_type understands -
41
+ # anything else (video podcasts, mostly) has no working feed/player support in octopod.
42
+ FORMATS_BY_MIME = {
43
+ 'audio/mpeg' => 'mp3', 'audio/mp3' => 'mp3',
44
+ 'audio/mp4' => 'm4a', 'audio/x-m4a' => 'm4a', 'audio/m4a' => 'm4a', 'audio/mp4a-latm' => 'm4a', 'audio/aac' => 'm4a',
45
+ 'audio/ogg' => 'ogg', 'audio/vorbis' => 'ogg', 'audio/opus' => 'opus'
46
+ }.freeze
47
+ FORMATS = %w[mp3 m4a ogg opus].freeze
48
+ IMAGE_EXTENSIONS = %w[jpg jpeg png gif webp].freeze
49
+
50
+ # Config lines in _config.yml.sample that are placeholders pointing at someone else's
51
+ # accounts - harmless on a demo site, wrong on a real imported one, so they get commented out.
52
+ PLACEHOLDER_CONFIG_KEYS = %w[itunes_url fediverse_url].freeze
53
+
54
+ attr_reader :target, :warnings
55
+
56
+ def initialize(source, target = nil, site_url: nil, limit: nil, force: false, download_enclosures: true,
57
+ fetcher: HttpFetcher.new, gem_spec: nil, out: $stdout)
58
+ @source = source
59
+ @target = target
60
+ @site_url = site_url
61
+ @limit = limit
62
+ @force = force
63
+ @download_enclosures = download_enclosures
64
+ @fetcher = fetcher
65
+ @gem_spec = gem_spec
66
+ @out = out
67
+ @warnings = []
68
+ end
69
+
70
+ def run
71
+ channel, items = load_feed
72
+ @target ||= File.join(Dir.pwd, slugify(text(channel, 'rss', 'title')) || 'podcast')
73
+
74
+ episodes = items.map { |item| parse_item(item) }.sort_by { |episode| episode[:date] }
75
+ episodes = episodes.last(@limit) if @limit
76
+ assign_slugs(episodes)
77
+ @download_url = enclosure_base_url(episodes) unless @download_enclosures
78
+ prepare_target
79
+
80
+ say "===== Creating site in #{@target} ====="
81
+ copy_site_skeleton
82
+ write_gemfile
83
+ channel_image = import_channel_image(channel)
84
+ write_config(channel)
85
+
86
+ formats = []
87
+ episodes.each_with_index do |episode, index|
88
+ say "", "[#{index + 1}/#{episodes.size}] #{episode[:title]}"
89
+ formats << episode[:format] if import_episode(episode, channel_image)
90
+ end
91
+ formats.uniq.each { |format| write_feed_marker(format) }
92
+
93
+ say "", "===== Imported #{episodes.size} episode(s) into #{@target} ====="
94
+ say "Review _config.yml (url, license, deploy settings) and imprint.md before publishing."
95
+ unless @warnings.empty?
96
+ say "", "Warnings:"
97
+ @warnings.each { |warning| say " - #{warning}" }
98
+ end
99
+ self
100
+ end
101
+
102
+ private
103
+
104
+ # --- Feed -------------------------------------------------------------------------------
105
+
106
+ # Returns the channel element of the first feed page plus the items of every page,
107
+ # following atom:link rel="next" the way jekyll-octopod's own paged feeds are linked.
108
+ def load_feed
109
+ items = []
110
+ seen = {}
111
+ channel = nil
112
+ location = @source
113
+
114
+ while location && !seen[location]
115
+ seen[location] = true
116
+ say "Reading feed #{location}"
117
+ document = REXML::Document.new(read_source(location))
118
+ page_channel = document.root && child(document.root, 'rss', 'channel')
119
+ raise ArgumentError, "#{location} is not an RSS feed (no <rss><channel> found)" unless page_channel
120
+
121
+ channel ||= page_channel
122
+ items.concat(children(page_channel, 'rss', 'item'))
123
+ next_link = children(page_channel, 'atom', 'link').find { |link| link.attributes['rel'] == 'next' }
124
+ location = next_link && absolute_url(next_link.attributes['href'], location)
125
+ end
126
+
127
+ [channel, items]
128
+ end
129
+
130
+ def read_source(location)
131
+ File.file?(location) ? File.read(location) : @fetcher.read(location)
132
+ end
133
+
134
+ def parse_item(item)
135
+ subtitle = text(item, 'itunes', 'subtitle')
136
+ title = text(item, 'rss', 'title') || text(item, 'itunes', 'title') || 'untitled'
137
+ # jekyll-octopod's own feed renders "<title> - <subtitle>" into <title>; undo that so a
138
+ # round trip doesn't end up with the subtitle shown twice.
139
+ title = title.delete_suffix(" - #{subtitle}") if subtitle && title.length > subtitle.length + 3
140
+
141
+ enclosure = child(item, 'rss', 'enclosure')
142
+ enclosure_url = enclosure && enclosure.attributes['url'].to_s.strip
143
+ enclosure_url = nil if enclosure_url&.empty?
144
+
145
+ { title: title,
146
+ link: text(item, 'rss', 'link'),
147
+ subtitle: subtitle,
148
+ summary: text(item, 'itunes', 'summary'),
149
+ date: parse_date(text(item, 'rss', 'pubDate')),
150
+ author: text(item, 'itunes', 'author'),
151
+ explicit: normalize_explicit(text(item, 'itunes', 'explicit')),
152
+ duration: normalize_duration(text(item, 'itunes', 'duration')),
153
+ tags: split_list(text(item, 'itunes', 'keywords')) + children(item, 'rss', 'category').map { |c| c.text.to_s.strip },
154
+ guid: text(item, 'rss', 'guid'),
155
+ enclosure_url: enclosure_url,
156
+ enclosure_length: enclosure && enclosure.attributes['length'].to_i,
157
+ format: enclosure_url && format_for(enclosure.attributes['type'], enclosure_url),
158
+ image_url: child(item, 'itunes', 'image')&.attributes&.[]('href'),
159
+ chapters: children(child(item, 'psc', 'chapters'), 'psc', 'chapter').map do |chapter|
160
+ "#{normalize_timestamp(chapter.attributes['start'])} #{chapter.attributes['title']}"
161
+ end,
162
+ chapters_url: children(item, 'podcast', 'chapters').first&.attributes&.[]('url'),
163
+ transcript_url: children(item, 'podcast', 'transcript')
164
+ .find { |t| t.attributes['type'].to_s.start_with?('text/vtt') }&.attributes&.[]('url'),
165
+ body: text(item, 'content', 'encoded') || text(item, 'rss', 'description') || '' }
166
+ end
167
+
168
+ # --- Site skeleton ----------------------------------------------------------------------
169
+
170
+ def prepare_target
171
+ if Dir.exist?(@target) && !Dir.empty?(@target) && !@force
172
+ raise ArgumentError, "#{@target} already exists and is not empty - pick another directory, " \
173
+ "or pass --force to import into it anyway (already downloaded media is kept)"
174
+ end
175
+ FileUtils.mkdir_p(@target)
176
+ end
177
+
178
+ # Same files 'octopod setup' copies, minus the demo episode (post, audio, feed markers):
179
+ # the feed markers are written per format actually found in the imported feed instead.
180
+ def copy_site_skeleton
181
+ assets = File.join(GEM_ROOT, 'assets')
182
+ Dir.glob(File.join(assets, '**', '*'), File::FNM_DOTMATCH).sort.each do |file|
183
+ relative = file.delete_prefix(assets + '/')
184
+ next if %w[. ..].include?(File.basename(relative))
185
+ next if relative == '_config.yml.sample' || relative.match?(/\Aepisodes\.\w+\.rss\z/)
186
+ next if relative.start_with?('_posts/', 'episodes/')
187
+
188
+ destination = File.join(@target, relative)
189
+ if File.directory?(file)
190
+ FileUtils.mkdir_p(destination)
191
+ else
192
+ FileUtils.mkdir_p(File.dirname(destination))
193
+ FileUtils.cp(file, destination)
194
+ end
195
+ end
196
+ FileUtils.mkdir_p(File.join(@target, '_posts'))
197
+ FileUtils.mkdir_p(File.join(@target, 'episodes'))
198
+ end
199
+
200
+ def write_gemfile
201
+ path = File.join(@target, 'Gemfile')
202
+ return if File.exist?(path)
203
+
204
+ bulma = gem_spec&.dependencies&.find { |d| d.name == 'jekyll-octopod-bulma' }&.requirement&.to_s
205
+ File.write(path, <<~GEMFILE)
206
+ source "https://rubygems.org"
207
+ gem 'jekyll', '~> 4.4'
208
+ gem 'jekyll-sass-converter', '~> 3.0'
209
+
210
+ group :jekyll_plugins do
211
+ gem 'jekyll-octopod', '~> #{VERSION::STRING}'
212
+ gem 'jekyll-octopod-bulma'#{bulma ? ", '#{bulma}'" : ''}
213
+ end
214
+ GEMFILE
215
+ end
216
+
217
+ def gem_spec
218
+ @gem_spec ||= begin
219
+ Gem::Specification.find_by_name('jekyll-octopod')
220
+ rescue Gem::MissingSpecError
221
+ nil
222
+ end
223
+ end
224
+
225
+ # Edits _config.yml.sample line by line rather than dumping a fresh YAML hash, so the
226
+ # sample's explanatory comments survive into the new site.
227
+ def write_config(channel)
228
+ config = File.read(File.join(GEM_ROOT, 'assets', '_config.yml.sample'))
229
+ owner = child(channel, 'itunes', 'owner')
230
+ email = text(owner, 'itunes', 'email') || text(channel, 'rss', 'managingEditor')&.sub(/\s*\(.*\)\s*\z/, '')
231
+ # podcast:license names the actual license (with a link to it), <copyright> often just the
232
+ # rights holder - so the former wins where a feed has both.
233
+ license_element = child(channel, 'podcast', 'license')
234
+ license = text(channel, 'podcast', 'license') || text(channel, 'rss', 'copyright')
235
+ license_url = license_element&.attributes&.[]('url')
236
+ categories = children(channel, 'itunes', 'category').map { |c| c.attributes['text'] }.compact
237
+
238
+ values = {
239
+ 'title' => text(channel, 'rss', 'title'),
240
+ 'url' => @site_url,
241
+ 'subtitle' => text(channel, 'itunes', 'subtitle'),
242
+ 'description' => text(channel, 'rss', 'description') || text(channel, 'itunes', 'summary'),
243
+ 'author' => text(channel, 'itunes', 'author') || text(owner, 'itunes', 'name'),
244
+ 'email' => email,
245
+ 'keywords' => split_list(text(channel, 'itunes', 'keywords')),
246
+ 'itunes_categories' => categories,
247
+ 'language' => text(channel, 'rss', 'language')&.split(/[-_]/)&.first&.downcase,
248
+ 'explicit' => normalize_explicit(text(channel, 'itunes', 'explicit')),
249
+ 'license' => license,
250
+ 'license_url' => license_url,
251
+ 'download_url' => @download_url
252
+ }
253
+ values.each do |key, value|
254
+ # Empty lists still replace the sample's placeholder keywords/categories.
255
+ next if value.nil? || (value.is_a?(String) && value.empty?)
256
+ config = config.sub(/^(# )?#{key}:.*$/) { "#{key}: #{JSON.generate(value)}" }
257
+ end
258
+
259
+ comment_out = PLACEHOLDER_CONFIG_KEYS.dup
260
+ # The sample's CC BY 4.0 link and badge would claim a license the feed never granted.
261
+ if license && license != 'CC BY 4.0'
262
+ comment_out << 'license_image_url'
263
+ comment_out << 'license_url' unless license_url
264
+ end
265
+ comment_out.each { |key| config = config.sub(/^#{key}:/, "# #{key}:") }
266
+
267
+ File.write(File.join(@target, '_config.yml'), config)
268
+ end
269
+
270
+ def write_feed_marker(format)
271
+ File.write(File.join(@target, "episodes.#{format}.rss"), "---\nlayout: feed\nformat: #{format}\n---\n")
272
+ end
273
+
274
+ # The theme hardcodes assets/img/logo-itunes.jpg (feeds) and assets/img/logo-360x360.png
275
+ # (sidebar, player poster), so the channel image has to land under exactly those names.
276
+ # Converting/resizing needs ImageMagick; without it, the image is only used where its
277
+ # format already matches the expected file extension. The download itself is kept as
278
+ # logo-original.* - a full-size source to derive other sizes from later, and what lets a
279
+ # --force re-run skip downloading it again.
280
+ def import_channel_image(channel)
281
+ url = child(channel, 'itunes', 'image')&.attributes&.[]('href') ||
282
+ text(child(channel, 'rss', 'image'), 'rss', 'url')
283
+ return nil unless url
284
+
285
+ url = absolute_url(url, @source)
286
+ img_dir = File.join(@target, 'assets', 'img')
287
+ FileUtils.mkdir_p(img_dir)
288
+ original = File.join(img_dir, "logo-original.#{image_extension(url)}")
289
+ return url unless download(url, original, label: 'podcast logo')
290
+
291
+ type = sniff_image_type(original)
292
+ if (magick = imagemagick)
293
+ converted = system(*magick, original, File.join(img_dir, 'logo-itunes.jpg'), err: File::NULL) &&
294
+ system(*magick, original, '-resize', '360x360', File.join(img_dir, 'logo-360x360.png'), err: File::NULL)
295
+ warn_about "ImageMagick couldn't convert the podcast logo (#{url}) - check assets/img/." unless converted
296
+ elsif type == 'jpg'
297
+ FileUtils.cp(original, File.join(img_dir, 'logo-itunes.jpg'))
298
+ warn_about "Podcast logo is a JPEG and ImageMagick isn't installed - assets/img/logo-360x360.png " \
299
+ "still shows the theme's default logo."
300
+ elsif type == 'png'
301
+ FileUtils.cp(original, File.join(img_dir, 'logo-360x360.png'))
302
+ warn_about "Podcast logo is a PNG and ImageMagick isn't installed - assets/img/logo-itunes.jpg " \
303
+ "(used in feeds) still shows the theme's default logo."
304
+ else
305
+ warn_about "Podcast logo saved as #{original.delete_prefix(@target + '/')}, but couldn't be converted " \
306
+ "(install ImageMagick) - the theme's default logos are still in use."
307
+ end
308
+ url
309
+ end
310
+
311
+ def imagemagick
312
+ %w[magick convert].each do |command|
313
+ return [command] if system(command, '-version', out: File::NULL, err: File::NULL)
314
+ end
315
+ nil
316
+ rescue SystemCallError
317
+ nil
318
+ end
319
+
320
+ # --- Episodes ---------------------------------------------------------------------------
321
+
322
+ # Returns true if the episode ended up with a local audio file.
323
+ def import_episode(episode, channel_image)
324
+ slug = episode[:slug]
325
+ front_matter = { 'title' => episode[:title] }
326
+ front_matter['subtitle'] = episode[:subtitle] if episode[:subtitle]
327
+ front_matter['date'] = episode[:date].strftime('%Y-%m-%d %H:%M:%S %z')
328
+ front_matter['layout'] = 'post'
329
+ front_matter['author'] = episode[:author] if episode[:author]
330
+ front_matter['explicit'] = episode[:explicit] if episode[:explicit]
331
+ front_matter['duration'] = episode[:duration] if episode[:duration]
332
+
333
+ audio_file = nil
334
+ if episode[:enclosure_url]
335
+ enclosure_url = absolute_url(episode[:enclosure_url], @source)
336
+ if FORMATS.include?(episode[:format]) && @download_url
337
+ audio_file = enclosure_url.delete_prefix("#{@download_url}/")
338
+ say " audio: linking #{enclosure_url}"
339
+ size = enclosure_size(episode, enclosure_url)
340
+ front_matter['filesize'] = { episode[:format] => size } if size
341
+ elsif FORMATS.include?(episode[:format])
342
+ audio_file = "#{slug}.#{episode[:format]}"
343
+ path = File.join(@target, 'episodes', audio_file)
344
+ audio_file = nil unless download(enclosure_url, path, label: 'audio')
345
+ else
346
+ warn_about "#{episode[:title]}: skipped enclosure #{episode[:enclosure_url]} - unsupported format " \
347
+ "(octopod handles #{FORMATS.join('/')})"
348
+ end
349
+ end
350
+ front_matter['audio'] = { episode[:format] => audio_file } if audio_file
351
+
352
+ if episode[:image_url] && absolute_url(episode[:image_url], @source) != channel_image
353
+ image = "episodes/#{slug}.#{image_extension(episode[:image_url])}"
354
+ if download(absolute_url(episode[:image_url], @source), File.join(@target, 'assets', 'img', image), label: 'image')
355
+ front_matter['image'] = image
356
+ end
357
+ end
358
+
359
+ front_matter['summary'] = episode[:summary] if episode[:summary]
360
+ front_matter['tags'] = episode[:tags].uniq unless episode[:tags].empty?
361
+ chapters = episode[:chapters].empty? ? fetch_json_chapters(episode) : episode[:chapters]
362
+ front_matter['chapters'] = chapters unless chapters.empty?
363
+ front_matter['guid'] = episode[:guid] if episode[:guid]
364
+
365
+ # Named after the audio file so PodlovePlayerTag#transcripts_for picks it up on its own.
366
+ # Still downloaded when the audio is only linked (the player parses it at build time),
367
+ # but then named explicitly, since there's no local audio file for it to sit next to.
368
+ if episode[:transcript_url] && audio_file &&
369
+ download(absolute_url(episode[:transcript_url], @source),
370
+ File.join(@target, 'episodes', "#{slug}.vtt"), label: 'transcript') && @download_url
371
+ front_matter['transcript'] = "#{slug}.vtt"
372
+ end
373
+
374
+ post_path = File.join(@target, '_posts', "#{episode[:date].strftime('%Y-%m-%d')}-#{slug}.md")
375
+ File.write(post_path, front_matter.to_yaml + "---\n\n" + post_body(episode[:body], audio_file))
376
+ say " wrote #{post_path.delete_prefix(@target + '/')}"
377
+ !audio_file.nil?
378
+ end
379
+
380
+ # Shownotes go in unchanged, wrapped in {% raw %} so any '{{' or '{%' in them can't break the
381
+ # Liquid pass, and - for HTML - in a single <div> so kramdown passes the whole block through
382
+ # untouched instead of reading indented lines inside it as code blocks.
383
+ def post_body(body, audio_file)
384
+ # Drop the empty mount point a jekyll-octopod feed's own {% podlove_player %} left behind;
385
+ # the imported post gets a fresh player tag instead.
386
+ body = body.gsub(%r{<div id="podlove-player-\w+">\s*</div>}, '').strip
387
+ content = +''
388
+ content << "{% podlove_player %}\n\n" if audio_file
389
+ return content if body.empty?
390
+
391
+ body = "<div class=\"shownotes\">\n#{body}\n</div>" if body.match?(/<[a-z][^>]*>/i)
392
+ content << "{% raw %}\n#{body}\n{% endraw %}\n"
393
+ end
394
+
395
+ # podcast:chapters points at a JSON chapters file rather than inlining them like psc does.
396
+ def fetch_json_chapters(episode)
397
+ return [] unless episode[:chapters_url]
398
+
399
+ # Podlove Publisher links a chapters URL for every episode and answers it with an empty
400
+ # body when an episode has none - that's "no chapters", not an error.
401
+ body = @fetcher.read(absolute_url(episode[:chapters_url], @source))
402
+ return [] if body.strip.empty?
403
+
404
+ data = JSON.parse(body)
405
+ (data['chapters'] || []).filter_map do |chapter|
406
+ next nil unless chapter['startTime'] && chapter['title']
407
+ "#{normalize_timestamp(chapter['startTime'].to_f)} #{chapter['title']}"
408
+ end
409
+ rescue StandardError => e
410
+ warn_about "#{episode[:title]}: couldn't read chapters from #{episode[:chapters_url]} (#{e.message})"
411
+ []
412
+ end
413
+
414
+ # Slugs come from the last path segment of the episode's old web page where there is one
415
+ # (".../2026/09/08/episode1.html" -> "episode1"), so a migrated site keeps its post and
416
+ # /players/<slug> URLs; from the title otherwise, and for query-string or purely numeric
417
+ # links ("?p=123", ".../episodes/42") that say nothing about the episode.
418
+ # The longest directory URL every supported enclosure lives under, without the trailing
419
+ # slash, for 'download_url'. Episodes spread over several hosts (a podcast that changed
420
+ # hosters, say) can't be expressed as one download_url, so that's an error rather than a
421
+ # silently broken feed.
422
+ def enclosure_base_url(episodes)
423
+ urls = episodes.select { |episode| episode[:enclosure_url] && FORMATS.include?(episode[:format]) }
424
+ .map { |episode| absolute_url(episode[:enclosure_url], @source) }
425
+ return nil if urls.empty?
426
+
427
+ prefix = urls.inject do |common, url|
428
+ length = common.each_char.zip(url.each_char).take_while { |a, b| a == b }.size
429
+ common[0, length]
430
+ end
431
+ prefix = prefix[0..prefix.rindex('/')] if prefix.include?('/')
432
+ unless prefix.match?(%r{\Ahttps?://[^/]+/}i)
433
+ raise ArgumentError, "--no-download needs all enclosures on one host, but they're spread over " \
434
+ "#{urls.map { |url| URI(url).host }.uniq.join(', ')} - import with downloads instead"
435
+ end
436
+ prefix.chomp('/')
437
+ end
438
+
439
+ # The feed's enclosure length, or - where feeds leave it at 0, which happens a lot - the
440
+ # Content-Length of a HEAD request, since the player and feed need a real size.
441
+ def enclosure_size(episode, url)
442
+ return episode[:enclosure_length] if episode[:enclosure_length].to_i > 0
443
+
444
+ size = @fetcher.size(url)
445
+ return size if size.to_i > 0
446
+ warn_about "#{episode[:title]}: no file size in the feed or from the server for #{url} - set 'filesize' by hand"
447
+ nil
448
+ rescue StandardError => e
449
+ warn_about "#{episode[:title]}: couldn't get the file size of #{url} (#{e.message}) - set 'filesize' by hand"
450
+ nil
451
+ end
452
+
453
+ # Oldest episode first, so when two episodes slugify the same, the earlier one keeps the
454
+ # plain slug and later ones get -2, -3, ...
455
+ def assign_slugs(episodes)
456
+ used = Hash.new(0)
457
+ episodes.each do |episode|
458
+ base = link_slug(episode[:link]) || slugify(episode[:title]) || 'episode'
459
+ used[base] += 1
460
+ episode[:slug] = used[base] == 1 ? base : "#{base}-#{used[base]}"
461
+ end
462
+ end
463
+
464
+ # Downloads into a '.part' file that's only renamed once complete, so an existing target
465
+ # file always means a finished earlier download - which is what makes re-running an
466
+ # interrupted import with --force skip everything it already has.
467
+ def download(url, path, label:)
468
+ if File.exist?(path) && File.size(path) > 0
469
+ say " #{label}: already downloaded, keeping #{path.delete_prefix(@target + '/')}"
470
+ return true
471
+ end
472
+
473
+ FileUtils.mkdir_p(File.dirname(path))
474
+ partial = "#{path}.part"
475
+ say " #{label}: #{url}"
476
+ @fetcher.download(url, partial)
477
+ FileUtils.mv(partial, path)
478
+ true
479
+ rescue StandardError => e
480
+ FileUtils.rm_f(partial) if partial
481
+ warn_about "couldn't download #{label} #{url} (#{e.message})"
482
+ false
483
+ end
484
+
485
+ # --- Helpers ----------------------------------------------------------------------------
486
+
487
+ def children(node, ns, name)
488
+ return [] unless node
489
+ node.elements.select { |element| element.name == name && NAMESPACES[ns].include?(element.namespace) }
490
+ end
491
+
492
+ def child(node, ns, name)
493
+ children(node, ns, name).first
494
+ end
495
+
496
+ # Text content of a child element (CDATA included), stripped, or nil if missing/blank.
497
+ def text(node, ns, name)
498
+ element = child(node, ns, name)
499
+ return nil unless element
500
+
501
+ value = element.texts.map(&:value).join.strip
502
+ value.empty? ? nil : value
503
+ end
504
+
505
+ def split_list(value)
506
+ value.to_s.split(',').map(&:strip).reject(&:empty?)
507
+ end
508
+
509
+ def parse_date(value)
510
+ return Time.now unless value
511
+ Time.rfc2822(value)
512
+ rescue ArgumentError
513
+ begin
514
+ Time.parse(value)
515
+ rescue ArgumentError
516
+ Time.now
517
+ end
518
+ end
519
+
520
+ def normalize_explicit(value)
521
+ case value.to_s.strip.downcase
522
+ when 'yes', 'true', 'explicit' then 'yes'
523
+ when 'no', 'false' then 'no'
524
+ when 'clean' then 'clean'
525
+ end
526
+ end
527
+
528
+ # itunes:duration may be plain seconds, MM:SS or H:MM:SS - always stored as HH:MM:SS.
529
+ def normalize_duration(value)
530
+ return nil unless value && value.match?(/\A\d+(:\d+){0,2}(\.\d+)?\z/)
531
+
532
+ seconds = value.split(':').map(&:to_f).inject(0) { |total, part| total * 60 + part }.to_i
533
+ format('%02d:%02d:%02d', seconds / 3600, seconds / 60 % 60, seconds % 60)
534
+ end
535
+
536
+ # Chapter starts (psc 'start' attributes, or seconds from JSON chapters) as HH:MM:SS.mmm,
537
+ # the form OctopodFilters#split_chapter and the sample episode use.
538
+ def normalize_timestamp(value)
539
+ seconds = if value.is_a?(Numeric)
540
+ value.to_f
541
+ else
542
+ value.to_s.split(':').map(&:to_f).inject(0) { |total, part| total * 60 + part }
543
+ end
544
+ millis = (seconds * 1000).round
545
+ format('%02d:%02d:%02d.%03d', millis / 3_600_000, millis / 60_000 % 60, millis / 1000 % 60, millis % 1000)
546
+ end
547
+
548
+ def format_for(mime, url)
549
+ mime = mime.to_s.split(';').first.to_s.strip.downcase
550
+ extension = File.extname(URI(url).path).delete('.').downcase rescue ''
551
+ return 'opus' if extension == 'opus'
552
+ FORMATS_BY_MIME[mime] || (FORMATS.include?(extension) ? extension : (extension.empty? ? mime : extension))
553
+ end
554
+
555
+ def image_extension(url)
556
+ extension = File.extname(URI(url).path).delete('.').downcase rescue ''
557
+ extension = 'jpg' if extension == 'jpeg'
558
+ IMAGE_EXTENSIONS.include?(extension) ? extension : 'jpg'
559
+ end
560
+
561
+ def sniff_image_type(path)
562
+ magic = File.binread(path, 8)
563
+ return 'jpg' if magic.start_with?("\xFF\xD8".b)
564
+ return 'png' if magic.start_with?("\x89PNG".b)
565
+ nil
566
+ end
567
+
568
+ def link_slug(link)
569
+ return nil unless link
570
+ slug = slugify(File.basename(URI(link).path.to_s, '.*'))
571
+ slug unless slug.nil? || slug.match?(/\A\d+\z/)
572
+ rescue URI::Error
573
+ nil
574
+ end
575
+
576
+ def slugify(value)
577
+ return nil unless value
578
+
579
+ slug = value.downcase
580
+ .gsub('ä', 'ae').gsub('ö', 'oe').gsub('ü', 'ue').gsub('ß', 'ss')
581
+ .unicode_normalize(:nfkd).gsub(/[^\x00-\x7F]/, '')
582
+ .gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')[0, 60].sub(/-+\z/, '')
583
+ slug.empty? ? nil : slug
584
+ end
585
+
586
+ def absolute_url(url, base)
587
+ return url if base.nil? || File.file?(base.to_s)
588
+ URI.join(base, url).to_s
589
+ rescue URI::Error
590
+ url
591
+ end
592
+
593
+ def say(*lines)
594
+ lines.each { |line| @out.puts line }
595
+ end
596
+
597
+ def warn_about(message)
598
+ @warnings << message
599
+ say " WARNING: #{message}"
600
+ end
601
+
602
+ # Plain Net::HTTP instead of open-uri: podcast hosts routinely bounce enclosures through
603
+ # several tracking redirects (sometimes https -> http, which open-uri refuses), and
604
+ # enclosures are streamed to disk instead of being held in memory.
605
+ class HttpFetcher
606
+ MAX_REDIRECTS = 10
607
+ USER_AGENT = "jekyll-octopod/#{VERSION::STRING} (+https://github.com/jekyll-octopod/jekyll-octopod)"
608
+
609
+ def read(url)
610
+ body = request(url) { |response| response.body }
611
+ body.force_encoding(Encoding::UTF_8)
612
+ end
613
+
614
+ def size(url)
615
+ request(url, method: Net::HTTP::Head) { |response| response['content-length'].to_i }
616
+ end
617
+
618
+ def download(url, path)
619
+ request(url) do |response|
620
+ total = response['content-length'].to_i
621
+ done = 0
622
+ File.open(path, 'wb') do |file|
623
+ response.read_body do |chunk|
624
+ file.write(chunk)
625
+ done += chunk.bytesize
626
+ print "\r #{done * 100 / total}% of #{total / 1_048_576} MB" if total > 0 && $stdout.tty?
627
+ end
628
+ end
629
+ puts if total > 0 && $stdout.tty?
630
+ end
631
+ end
632
+
633
+ private
634
+
635
+ def request(url, redirects_left = MAX_REDIRECTS, method: Net::HTTP::Get, &block)
636
+ uri = URI(url)
637
+ raise ArgumentError, "not an http(s) URL: #{url}" unless uri.is_a?(URI::HTTP)
638
+
639
+ redirect = nil
640
+ result = nil
641
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
642
+ open_timeout: 30, read_timeout: 300) do |http|
643
+ http.request(method.new(uri.request_uri, 'User-Agent' => USER_AGENT)) do |response|
644
+ case response
645
+ when Net::HTTPRedirection then redirect = URI.join(url, response['location']).to_s
646
+ when Net::HTTPSuccess then result = block.call(response)
647
+ else raise "HTTP #{response.code} #{response.message}"
648
+ end
649
+ end
650
+ end
651
+ return result unless redirect
652
+ raise "too many redirects" if redirects_left.zero?
653
+
654
+ request(redirect, redirects_left - 1, method: method, &block)
655
+ end
656
+ end
657
+ end
658
+ end
659
+ end
@@ -2,7 +2,7 @@ module Jekyll
2
2
  class Octopod
3
3
  module VERSION #:nodoc:
4
4
  MAJOR = 0
5
- MINOR = 21
5
+ MINOR = 23
6
6
  TINY = 0
7
7
 
8
8
  STRING = [MAJOR, MINOR, TINY].join('.')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-octopod
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arne Eilermann
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '1.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rexml
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.4'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.4'
97
111
  description: Enables you to publish your podcast using the Jekyll static site generator,
98
112
  creating feeds and a reasonably looking website
99
113
  email:
@@ -131,6 +145,7 @@ files:
131
145
  - lib/jekyll/theme_layout.rb
132
146
  - lib/jekyll/update_config.rb
133
147
  - lib/kramdown/parser/noopener_gfm.rb
148
+ - lib/octopod/importer.rb
134
149
  - lib/octopod/version.rb
135
150
  homepage: https://github.com/jekyll-octopod/jekyll-octopod
136
151
  licenses: