eleventy-plugin-podcaster 2.0.0-alpha.2 → 2.0.0-alpha.4
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.
- package/.env +5 -0
- package/eleventy.config.js +12 -2
- package/package.json +4 -2
- package/src/calculateEpisodeFilename.js +41 -0
- package/src/calculateEpisodeSizeAndDuration.js +207 -0
- package/src/drafts.js +2 -2
- package/src/episodeData.js +5 -3
- package/src/excerpts.js +2 -1
- package/src/isEpisodePost.js +10 -0
- package/src/podcastFeed.js +4 -3
- package/src/podcastFeed.njk +2 -2
- package/src/calculateFilenameSizeAndDuration.js +0 -106
package/.env
ADDED
package/eleventy.config.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
1
3
|
import podcastFeed from './src/podcastFeed.js'
|
|
2
4
|
import podcastData from './src/podcastData.js'
|
|
3
5
|
import episodeData from './src/episodeData.js'
|
|
4
|
-
import
|
|
6
|
+
import calculateEpisodeSizeAndDuration from './src/calculateEpisodeSizeAndDuration.js'
|
|
7
|
+
import calculateEpisodeFilename from './src/calculateEpisodeFilename.js'
|
|
8
|
+
|
|
5
9
|
import readableFilters from './src/readableFilters.js'
|
|
6
10
|
import excerpts from './src/excerpts.js'
|
|
7
11
|
import drafts from './src/drafts.js'
|
|
8
12
|
import pageTitle from './src/pageTitle.js'
|
|
9
13
|
|
|
10
14
|
export default function (eleventyConfig, options = {}) {
|
|
15
|
+
const episodePostsDirectory = options.episodePostsDirectory ?? 'episode-posts'
|
|
16
|
+
options.episodePostsDirectory = path.join(eleventyConfig.directories.input, episodePostsDirectory)
|
|
17
|
+
const episodeFilesDirectory = options.episodeFilesDirectory ?? 'episode-files'
|
|
18
|
+
options.episodeFilesDirectory = path.join(eleventyConfig.directories.input, episodeFilesDirectory)
|
|
19
|
+
|
|
11
20
|
eleventyConfig.addPlugin(podcastFeed, options)
|
|
12
21
|
eleventyConfig.addPlugin(podcastData, options)
|
|
13
22
|
eleventyConfig.addPlugin(episodeData, options)
|
|
14
|
-
eleventyConfig.addPlugin(
|
|
23
|
+
eleventyConfig.addPlugin(calculateEpisodeSizeAndDuration, options)
|
|
24
|
+
eleventyConfig.addPlugin(calculateEpisodeFilename, options)
|
|
15
25
|
|
|
16
26
|
// Filters
|
|
17
27
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eleventy-plugin-podcaster",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.4",
|
|
4
4
|
"description": "An Eleventy plugin that allows you to create a podcast and its accompanying website",
|
|
5
5
|
"main": "eleventy.config.js",
|
|
6
6
|
"exports": {
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@11ty/eleventy": "^3.0.0",
|
|
32
32
|
"@11ty/eleventy-plugin-rss": "^2.0.1",
|
|
33
|
+
"@aws-sdk/client-s3": "^3.862.0",
|
|
33
34
|
"@tsmx/human-readable": "^2.0.3",
|
|
34
|
-
"chalk": "^5.3.0",
|
|
35
35
|
"dom-serializer": "^2.0.0",
|
|
36
36
|
"htmlparser2": "^9.1.0",
|
|
37
37
|
"luxon": "^3.4.4",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"ava": "^6.1.3",
|
|
43
|
+
"dotenv": "^17.2.1",
|
|
43
44
|
"fast-xml-parser": "^4.4.0",
|
|
45
|
+
"mock-aws-s3-v3": "^6.0.5",
|
|
44
46
|
"neostandard": "^0.11.4"
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import isEpisodePost from './isEpisodePost.js'
|
|
2
|
+
|
|
3
|
+
function findMatchingFilename (episodeData, thisEpisode) {
|
|
4
|
+
const filenameSeasonAndEpisodePattern =
|
|
5
|
+
/^.*?\b[sS](?<seasonNumber>\d+)\s*[eE](?<episodeNumber>\d+)\b.*\.(mp3|m4a)$/
|
|
6
|
+
const filenameEpisodePattern = /^.*?\b(?<episodeNumber>\d+)\b.*\.(mp3|m4a)$/
|
|
7
|
+
const { seasonNumber, episodeNumber } = thisEpisode
|
|
8
|
+
|
|
9
|
+
for (const file of Object.keys(episodeData)) {
|
|
10
|
+
if (seasonNumber && episodeNumber) {
|
|
11
|
+
const seasonAndEpisodeMatch = file.match(filenameSeasonAndEpisodePattern)
|
|
12
|
+
if (seasonAndEpisodeMatch) {
|
|
13
|
+
const matchedSeasonNumber = parseInt(seasonAndEpisodeMatch.groups.seasonNumber)
|
|
14
|
+
const matchedEpisodeNumber = parseInt(seasonAndEpisodeMatch.groups.episodeNumber)
|
|
15
|
+
if (matchedSeasonNumber === seasonNumber &&
|
|
16
|
+
matchedEpisodeNumber === episodeNumber) {
|
|
17
|
+
return file
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
} else if (episodeNumber) {
|
|
21
|
+
const episodeMatch = file.match(filenameEpisodePattern)
|
|
22
|
+
if (episodeMatch) {
|
|
23
|
+
const matchedEpisodeNumber = parseInt(episodeMatch.groups.episodeNumber)
|
|
24
|
+
if (matchedEpisodeNumber === episodeNumber) {
|
|
25
|
+
return file
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function (eleventyConfig, options) {
|
|
33
|
+
eleventyConfig.addGlobalData('eleventyComputed.episode.filename', () => {
|
|
34
|
+
return data => {
|
|
35
|
+
if (data.episode.filename) return data.episode.filename
|
|
36
|
+
if (!isEpisodePost(data, options)) return
|
|
37
|
+
|
|
38
|
+
return findMatchingFilename(data.episodeData, data.episode)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Duration, DateTime } from 'luxon'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { readdir, stat, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { Writable } from 'node:stream'
|
|
6
|
+
import { S3Client, ListObjectsCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
|
|
7
|
+
import { parseFile as parseFileMetadata, parseBuffer as parseBufferMetadata } from 'music-metadata'
|
|
8
|
+
import hr from '@tsmx/human-readable'
|
|
9
|
+
import isEpisodePost from './isEpisodePost.js'
|
|
10
|
+
|
|
11
|
+
const isAudioFile = episodeFilename => episodeFilename.endsWith('.mp3') ||
|
|
12
|
+
episodeFilename.endsWith('.m4a')
|
|
13
|
+
|
|
14
|
+
const convertSecondsToReadableDuration = seconds =>
|
|
15
|
+
Duration.fromMillis(seconds * 1000)
|
|
16
|
+
.shiftTo('days', 'hours', 'minutes', 'seconds')
|
|
17
|
+
.toHuman()
|
|
18
|
+
|
|
19
|
+
const convertReadableDurationToSeconds = duration => {
|
|
20
|
+
const durationPattern = /^(?:(?<hours>\d+):)?(?<minutes>\d{1,2}):(?<seconds>\d{2}(?:\.\d+)?)$/
|
|
21
|
+
|
|
22
|
+
let match
|
|
23
|
+
if (duration?.match) {
|
|
24
|
+
match = duration.match(durationPattern)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (match) {
|
|
28
|
+
const hours = isNaN(parseInt(match.groups.hours))
|
|
29
|
+
? 0
|
|
30
|
+
: parseInt(match.groups.hours)
|
|
31
|
+
const minutes = parseInt(match.groups.minutes)
|
|
32
|
+
const seconds = parseFloat(match.groups.seconds)
|
|
33
|
+
return hours * 3600 + minutes * 60 + seconds
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readEpisodeDataLocally (episodeFilesDirectory) {
|
|
38
|
+
const episodes = await readdir(episodeFilesDirectory)
|
|
39
|
+
const episodeData = {}
|
|
40
|
+
for (const episode of episodes) {
|
|
41
|
+
if (!isAudioFile(episode)) continue
|
|
42
|
+
|
|
43
|
+
const episodePath = path.join(episodeFilesDirectory, episode)
|
|
44
|
+
const episodeSize = (await stat(episodePath)).size
|
|
45
|
+
const episodeMetadata = await parseFileMetadata(episodePath, { duration: true })
|
|
46
|
+
const episodeDuration = episodeMetadata.format.duration
|
|
47
|
+
episodeData[episode] = {
|
|
48
|
+
size: episodeSize,
|
|
49
|
+
duration: episodeDuration
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return episodeData
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function calculatePodcastData (episodeData) {
|
|
56
|
+
const episodeDataValues = Object.values(episodeData)
|
|
57
|
+
const numberOfEpisodes = episodeDataValues.length
|
|
58
|
+
const totalSize = episodeDataValues.map(x => x.size).reduce((x, y) => x + y)
|
|
59
|
+
const totalDuration = episodeDataValues.map(x => x.duration).reduce((x, y) => x + y)
|
|
60
|
+
return { numberOfEpisodes, totalSize, totalDuration }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function reportPodcastData (podcastData) {
|
|
64
|
+
const { numberOfEpisodes, totalSize, totalDuration } = podcastData
|
|
65
|
+
console.log(`\u001b[33m${numberOfEpisodes} episodes; ${hr.fromBytes(totalSize)}; ${convertSecondsToReadableDuration(totalDuration)}.\u001b[0m`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writePodcastDataLocally (episodeData, podcastData, directories) {
|
|
69
|
+
const dataDir = path.join(process.cwd(), directories.data)
|
|
70
|
+
await writeFile(path.join(dataDir, 'episodeData.json'), JSON.stringify(episodeData, null, 2))
|
|
71
|
+
await writeFile(path.join(dataDir, 'podcastData.json'), JSON.stringify(podcastData, null, 2))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getS3Client (options) {
|
|
75
|
+
if (options.s3ClientObject) return options.s3ClientObject
|
|
76
|
+
|
|
77
|
+
if (options.s3Client) {
|
|
78
|
+
return new S3Client({
|
|
79
|
+
forcePathStyle: true,
|
|
80
|
+
endpoint: options.s3Client.endpoint,
|
|
81
|
+
region: options.s3Client.region,
|
|
82
|
+
credentials: {
|
|
83
|
+
accessKeyId: options.s3Client.accessKey,
|
|
84
|
+
secretAccessKey: options.s3Client.secretKey
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function getObjectFromS3Bucket (s3Client, s3Bucket, key) {
|
|
91
|
+
const getObjectResponse = await s3Client.send(new GetObjectCommand({ Bucket: s3Bucket, Key: key }))
|
|
92
|
+
|
|
93
|
+
const chunks = []
|
|
94
|
+
if (typeof getObjectResponse.Body.pipe === 'function') {
|
|
95
|
+
// this is to cope with the behaviour of the mock, which doesn't return an iterator full of chunks
|
|
96
|
+
const writable = new Writable({
|
|
97
|
+
write (chunk, encoding, callback) {
|
|
98
|
+
chunks.push(chunk)
|
|
99
|
+
callback()
|
|
100
|
+
},
|
|
101
|
+
final (callback) {
|
|
102
|
+
callback()
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
getObjectResponse.Body.pipe(writable)
|
|
106
|
+
await new Promise((resolve, reject) => {
|
|
107
|
+
writable.on('finish', resolve)
|
|
108
|
+
writable.on('error', reject)
|
|
109
|
+
})
|
|
110
|
+
} else {
|
|
111
|
+
for await (const chunk of getObjectResponse.Body) {
|
|
112
|
+
chunks.push(chunk)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const buffer = Buffer.concat(chunks)
|
|
116
|
+
return { buffer, lastModified: getObjectResponse.LastModified }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function getStoredEpisodeDataFromS3Bucket (s3Client, s3BucketName) {
|
|
120
|
+
try {
|
|
121
|
+
const { buffer, lastModified } = await getObjectFromS3Bucket(s3Client, s3BucketName, 'episodeData.json')
|
|
122
|
+
return { episodeData: JSON.parse(buffer.toString()), lastModified }
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { episodeData: {}, lastModified: null }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function updateEpisodeDataFromS3Bucket (s3Client, s3Bucket) {
|
|
129
|
+
const storedEpisodeData = await getStoredEpisodeDataFromS3Bucket(s3Client, s3Bucket)
|
|
130
|
+
const storedEpisodeDataLastModifiedDate = (storedEpisodeData.lastModified)
|
|
131
|
+
? DateTime.fromISO(storedEpisodeData.lastModified)
|
|
132
|
+
: null
|
|
133
|
+
const list = await s3Client.send(new ListObjectsCommand({ Bucket: s3Bucket }))
|
|
134
|
+
const result = { ...storedEpisodeData.episodeData }
|
|
135
|
+
for (const item of list.Contents ?? []) {
|
|
136
|
+
if (!isAudioFile(item.Key)) continue
|
|
137
|
+
|
|
138
|
+
const { Key: filename, Size: size, LastModified: lastModified } = item
|
|
139
|
+
|
|
140
|
+
if (!(filename in result) ||
|
|
141
|
+
!('size' in result[filename]) ||
|
|
142
|
+
!('duration' in result[filename]) ||
|
|
143
|
+
!storedEpisodeDataLastModifiedDate ||
|
|
144
|
+
storedEpisodeDataLastModifiedDate > DateTime.fromISO(lastModified)) {
|
|
145
|
+
const { buffer } = await getObjectFromS3Bucket(s3Client, s3Bucket, filename)
|
|
146
|
+
const metadata = await parseBufferMetadata(buffer, null, { duration: true })
|
|
147
|
+
const duration = metadata.format.duration
|
|
148
|
+
result[filename] = { size, duration }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function writeEpisodeDataToS3Bucket (s3Client, s3Bucket, episodeData) {
|
|
155
|
+
await s3Client.send(new PutObjectCommand({
|
|
156
|
+
Bucket: s3Bucket,
|
|
157
|
+
Key: 'episodeData.json',
|
|
158
|
+
Body: JSON.stringify(episodeData, null, 2),
|
|
159
|
+
ContentType: 'application/json'
|
|
160
|
+
}))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default function (eleventyConfig, options = {}) {
|
|
164
|
+
let firstRun = true
|
|
165
|
+
eleventyConfig.on('eleventy.before', async ({ directories }) => {
|
|
166
|
+
if (!firstRun || process.env.SKIP_EPISODE_CALCULATIONS === 'true') return
|
|
167
|
+
firstRun = false
|
|
168
|
+
|
|
169
|
+
const episodeFilesDirectory = options.episodeFilesDirectory
|
|
170
|
+
let episodeData
|
|
171
|
+
if (existsSync(episodeFilesDirectory)) {
|
|
172
|
+
episodeData = await readEpisodeDataLocally(episodeFilesDirectory)
|
|
173
|
+
} else if (options.s3ClientObject || options.s3Client) {
|
|
174
|
+
const s3Client = getS3Client(options)
|
|
175
|
+
const s3Bucket = options.s3Client.bucket
|
|
176
|
+
episodeData = await updateEpisodeDataFromS3Bucket(s3Client, s3Bucket)
|
|
177
|
+
await writeEpisodeDataToS3Bucket(s3Client, s3Bucket, episodeData)
|
|
178
|
+
} else {
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const podcastData = calculatePodcastData(episodeData)
|
|
182
|
+
await writePodcastDataLocally(episodeData, podcastData, directories)
|
|
183
|
+
if (!eleventyConfig.quietMode) reportPodcastData(podcastData)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
eleventyConfig.addGlobalData('eleventyComputed.episode.size', () => {
|
|
187
|
+
return data => {
|
|
188
|
+
if (data.episode.size) return data.episode.size
|
|
189
|
+
if (isEpisodePost(data, options) && data.episodeData) {
|
|
190
|
+
return data.episodeData[data.episode.filename]?.size
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
eleventyConfig.addGlobalData('eleventyComputed.episode.duration', () => {
|
|
196
|
+
return data => {
|
|
197
|
+
if (data.episode.duration) {
|
|
198
|
+
const convertedReadableDuration = convertReadableDurationToSeconds(data.episode.duration)
|
|
199
|
+
return convertedReadableDuration ?? data.episode.duration
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (isEpisodePost(data, options) && data.episodeData) {
|
|
203
|
+
return data.episodeData[data.episode.filename]?.duration
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
package/src/drafts.js
CHANGED
|
@@ -13,9 +13,9 @@ export default (eleventyConfig, options = {}) => {
|
|
|
13
13
|
}
|
|
14
14
|
if (!hasLoggedAboutDrafts) {
|
|
15
15
|
if (shouldIncludeDrafts) {
|
|
16
|
-
console.log('Including drafts.')
|
|
16
|
+
if (!eleventyConfig.quietMode) console.log('Including drafts.')
|
|
17
17
|
} else {
|
|
18
|
-
console.log('Excluding drafts.')
|
|
18
|
+
if (!eleventyConfig.quietMode) console.log('Excluding drafts.')
|
|
19
19
|
}
|
|
20
20
|
hasLoggedAboutDrafts = true
|
|
21
21
|
}
|
package/src/episodeData.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import isEpisodePost from './isEpisodePost.js'
|
|
2
|
+
|
|
1
3
|
export default function (eleventyConfig, options = {}) {
|
|
2
4
|
const postFilenameSeasonAndEpisodePattern =
|
|
3
5
|
/^[sS](?<seasonNumber>\d+)[eE](?<episodeNumber>\d+)/i
|
|
@@ -7,7 +9,7 @@ export default function (eleventyConfig, options = {}) {
|
|
|
7
9
|
return data => {
|
|
8
10
|
if (data.episode?.seasonNumber) return data.episode.seasonNumber
|
|
9
11
|
|
|
10
|
-
if (!data
|
|
12
|
+
if (!isEpisodePost(data, options)) return
|
|
11
13
|
|
|
12
14
|
const seasonAndEpisodeMatch = data.page.fileSlug.match(postFilenameSeasonAndEpisodePattern)
|
|
13
15
|
if (seasonAndEpisodeMatch) {
|
|
@@ -20,7 +22,7 @@ export default function (eleventyConfig, options = {}) {
|
|
|
20
22
|
return data => {
|
|
21
23
|
if (data.episode?.episodeNumber) return data.episode.episodeNumber
|
|
22
24
|
|
|
23
|
-
if (!data
|
|
25
|
+
if (!isEpisodePost(data, options)) return
|
|
24
26
|
|
|
25
27
|
const seasonAndEpisodeMatch = data.page.fileSlug.match(postFilenameSeasonAndEpisodePattern)
|
|
26
28
|
if (seasonAndEpisodeMatch) {
|
|
@@ -49,7 +51,7 @@ export default function (eleventyConfig, options = {}) {
|
|
|
49
51
|
|
|
50
52
|
eleventyConfig.addGlobalData('eleventyComputed.episode.url', () => {
|
|
51
53
|
return data => {
|
|
52
|
-
if (!data
|
|
54
|
+
if (!isEpisodePost(data, options)) return
|
|
53
55
|
|
|
54
56
|
const episodeUrlBase = data.podcast.episodeUrlBase
|
|
55
57
|
const filename = data.episode.filename
|
package/src/excerpts.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as htmlparser2 from 'htmlparser2'
|
|
2
2
|
import render from 'dom-serializer'
|
|
3
3
|
import markdownIt from 'markdown-it'
|
|
4
|
+
import isEpisodePost from './isEpisodePost.js'
|
|
4
5
|
|
|
5
6
|
export default function (eleventyConfig, options = {}) {
|
|
6
7
|
eleventyConfig.addGlobalData('eleventyComputed.excerpt', () => {
|
|
7
8
|
return (data) => {
|
|
8
|
-
if (!data
|
|
9
|
+
if (!isEpisodePost(data, options)) return
|
|
9
10
|
|
|
10
11
|
const md = markdownIt({
|
|
11
12
|
html: true,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
export default function isEpisodePost (data, options) {
|
|
4
|
+
if (data.page?.inputPath) {
|
|
5
|
+
const importPath = path.normalize(data.page.inputPath)
|
|
6
|
+
return importPath.startsWith(options.episodePostsDirectory)
|
|
7
|
+
} else {
|
|
8
|
+
return false
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/podcastFeed.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import rssPlugin from '@11ty/eleventy-plugin-rss'
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import isEpisodePost from './isEpisodePost.js'
|
|
4
5
|
|
|
5
6
|
export default function (eleventyConfig, options = {}) {
|
|
6
7
|
if (!('addTemplate' in eleventyConfig)) {
|
|
@@ -12,7 +13,7 @@ export default function (eleventyConfig, options = {}) {
|
|
|
12
13
|
eleventyConfig.addTemplate('feed.njk', readFileSync(podcastFeedPath), {
|
|
13
14
|
eleventyExcludeFromCollections: true,
|
|
14
15
|
eleventyImport: {
|
|
15
|
-
collections: ['
|
|
16
|
+
collections: ['episodePost']
|
|
16
17
|
}
|
|
17
18
|
})
|
|
18
19
|
|
|
@@ -22,7 +23,7 @@ export default function (eleventyConfig, options = {}) {
|
|
|
22
23
|
}
|
|
23
24
|
})
|
|
24
25
|
|
|
25
|
-
eleventyConfig.addCollection('
|
|
26
|
-
return collectionApi.
|
|
26
|
+
eleventyConfig.addCollection('episodePost', (collectionApi) => {
|
|
27
|
+
return collectionApi.getAll().filter(item => isEpisodePost(item.data, options))
|
|
27
28
|
})
|
|
28
29
|
}
|
package/src/podcastFeed.njk
CHANGED
|
@@ -17,7 +17,7 @@ eleventyAllowMissingExtension: true
|
|
|
17
17
|
<description>{{ podcast.description }}</description>
|
|
18
18
|
<language>{{ podcast.language }}</language>
|
|
19
19
|
<copyright>{{ podcast.copyrightNotice }}</copyright>
|
|
20
|
-
<pubDate>{{ collections.
|
|
20
|
+
<pubDate>{{ collections.episodePost | getNewestCollectionItemDate | dateToRfc3339 }}</pubDate>
|
|
21
21
|
<lastBuildDate>{{ podcast.feedLastBuildDate }}</lastBuildDate>
|
|
22
22
|
<itunes:image href="{{ podcast.imagePath | htmlBaseUrl(siteUrl) }}"></itunes:image>
|
|
23
23
|
{%- if podcast.subcategory %}
|
|
@@ -47,7 +47,7 @@ eleventyAllowMissingExtension: true
|
|
|
47
47
|
</itunes:owner>
|
|
48
48
|
{%- endif %}
|
|
49
49
|
|
|
50
|
-
{% for post in collections.
|
|
50
|
+
{% for post in collections.episodePost | reverse %}
|
|
51
51
|
<item>
|
|
52
52
|
<title>{{ post.data.episode.title or post.data.title }}</title>
|
|
53
53
|
{% if post.data.episode.itunesTitle %}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { Duration } from 'luxon'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { existsSync } from 'node:fs'
|
|
4
|
-
import { readdir, stat, writeFile } from 'node:fs/promises'
|
|
5
|
-
import { parseFile } from 'music-metadata'
|
|
6
|
-
import hr from '@tsmx/human-readable'
|
|
7
|
-
import chalk from 'chalk'
|
|
8
|
-
|
|
9
|
-
const convertSecondsToReadableDuration = seconds =>
|
|
10
|
-
Duration.fromMillis(seconds * 1000)
|
|
11
|
-
.shiftTo('days', 'hours', 'minutes', 'seconds')
|
|
12
|
-
.toHuman()
|
|
13
|
-
|
|
14
|
-
export default function (eleventyConfig) {
|
|
15
|
-
let firstRun = true
|
|
16
|
-
eleventyConfig.on('eleventy.before', async ({ directories }) => {
|
|
17
|
-
// don't keep recalculating episode data in serve mode
|
|
18
|
-
if (!firstRun || process.env.SKIP_EPISODE_CALCULATIONS === 'true') return
|
|
19
|
-
firstRun = false
|
|
20
|
-
const episodesDir = path.join(directories.input, 'episodeFiles')
|
|
21
|
-
if (!existsSync(episodesDir)) return
|
|
22
|
-
|
|
23
|
-
const episodes = await readdir(episodesDir)
|
|
24
|
-
const episodeData = {}
|
|
25
|
-
let numberOfEpisodes = 0
|
|
26
|
-
let totalSize = 0
|
|
27
|
-
let totalDuration = 0
|
|
28
|
-
|
|
29
|
-
for (const episode of episodes) {
|
|
30
|
-
if (!episode.endsWith('.mp3')) continue
|
|
31
|
-
|
|
32
|
-
numberOfEpisodes++
|
|
33
|
-
const episodePath = path.join(episodesDir, episode)
|
|
34
|
-
const episodeSize = (await stat(episodePath)).size
|
|
35
|
-
totalSize += episodeSize
|
|
36
|
-
const episodeMetadata = await parseFile(episodePath, { duration: true })
|
|
37
|
-
const episodeDuration = episodeMetadata.format.duration
|
|
38
|
-
totalDuration += episodeDuration
|
|
39
|
-
episodeData[episode] = {
|
|
40
|
-
size: episodeSize,
|
|
41
|
-
duration: Math.round(episodeDuration * 1000) / 1000
|
|
42
|
-
}
|
|
43
|
-
totalDuration = Math.round(totalDuration * 1000) / 1000
|
|
44
|
-
}
|
|
45
|
-
const podcastData = { numberOfEpisodes, totalSize, totalDuration }
|
|
46
|
-
|
|
47
|
-
const dataDir = path.join(process.cwd(), directories.data)
|
|
48
|
-
await writeFile(path.join(dataDir, 'episodeData.json'), JSON.stringify(episodeData, null, 2))
|
|
49
|
-
await writeFile(path.join(dataDir, 'podcastData.json'), JSON.stringify(podcastData, null, 2))
|
|
50
|
-
|
|
51
|
-
console.log(chalk.yellow(`${numberOfEpisodes} episodes; ${hr.fromBytes(totalSize)}; ${convertSecondsToReadableDuration(totalDuration)}.`))
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const filenameSeasonAndEpisodePattern =
|
|
55
|
-
/^.*?\b[sS](?<seasonNumber>\d+)\s*[eE](?<episodeNumber>\d+)\b.*\.mp3$/
|
|
56
|
-
const filenameEpisodePattern = /^.*?\b(?<episodeNumber>\d+)\b.*\.mp3$/
|
|
57
|
-
|
|
58
|
-
eleventyConfig.addGlobalData('eleventyComputed.episode.filename', () => {
|
|
59
|
-
return data => {
|
|
60
|
-
if (data.episode.filename) return data.episode.filename
|
|
61
|
-
|
|
62
|
-
if (!data.page.inputPath.includes('/episodePosts/')) return
|
|
63
|
-
|
|
64
|
-
for (const file of Object.keys(data.episodeData)) {
|
|
65
|
-
if (data.episode.seasonNumber && data.episode.episodeNumber) {
|
|
66
|
-
const seasonAndEpisodeMatch = file.match(filenameSeasonAndEpisodePattern)
|
|
67
|
-
if (seasonAndEpisodeMatch) {
|
|
68
|
-
const matchedSeasonNumber = parseInt(seasonAndEpisodeMatch.groups.seasonNumber)
|
|
69
|
-
const matchedEpisodeNumber = parseInt(seasonAndEpisodeMatch.groups.episodeNumber)
|
|
70
|
-
if (matchedSeasonNumber === data.episode.seasonNumber &&
|
|
71
|
-
matchedEpisodeNumber === data.episode.episodeNumber) {
|
|
72
|
-
return file
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
} else if (data.episode.episodeNumber) {
|
|
76
|
-
const episodeMatch = file.match(filenameEpisodePattern)
|
|
77
|
-
if (episodeMatch) {
|
|
78
|
-
const matchedEpisodeNumber = parseInt(episodeMatch.groups.episodeNumber)
|
|
79
|
-
if (matchedEpisodeNumber === data.episode.episodeNumber) {
|
|
80
|
-
return file
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
eleventyConfig.addGlobalData('eleventyComputed.episode.size', () => {
|
|
89
|
-
return data => {
|
|
90
|
-
if (data.episode.size) return data.episode.size
|
|
91
|
-
if (data.page.inputPath.includes('/episodePosts/') && data.episodeData) {
|
|
92
|
-
return data.episodeData[data.episode.filename]?.size
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
eleventyConfig.addGlobalData('eleventyComputed.episode.duration', () => {
|
|
98
|
-
return data => {
|
|
99
|
-
if (data.episode.duration) return data.episode.duration
|
|
100
|
-
|
|
101
|
-
if (data.page.inputPath.includes('/episodePosts/') && data.episodeData) {
|
|
102
|
-
return data.episodeData[data.episode.filename]?.duration
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
})
|
|
106
|
-
}
|