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 +4 -4
- data/bin/octopod +60 -1
- data/lib/jekyll/podlove_player_tag.rb +7 -1
- data/lib/octopod/importer.rb +659 -0
- data/lib/octopod/version.rb +1 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e8a41ff862f72c1aec7028926e077d7bd3d6f202a3139b2e733d1ea2033b2df
|
|
4
|
+
data.tar.gz: 3a9f0249e0039acc36666d3e3c74d450864677b73967b63ff8f79e75a7cf90d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
data/lib/octopod/version.rb
CHANGED
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.
|
|
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:
|