eleventy-plugin-podcaster 2.0.0-alpha.2 → 2.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env ADDED
@@ -0,0 +1,5 @@
1
+ BUCKET_SECRET_KEY="GiBsTUbU7VwKdSZGZKvYb47i87lWjkUXRH1Edd+I2KQ"
2
+ BUCKET_ACCESS_KEY="DO801WKX4LJP3Y9DWE2H"
3
+ BUCKET_ENDPOINT="https://sfo3.digitaloceanspaces.com"
4
+ BUCKET_REGION="us-east-1"
5
+ BUCKET_NAME="startlingbarbarabain"
@@ -1,7 +1,9 @@
1
1
  import podcastFeed from './src/podcastFeed.js'
2
2
  import podcastData from './src/podcastData.js'
3
3
  import episodeData from './src/episodeData.js'
4
- import calculateFilenameSizeAndDuration from './src/calculateFilenameSizeAndDuration.js'
4
+ import calculateEpisodeSizeAndDuration from './src/calculateEpisodeSizeAndDuration.js'
5
+ import calculateEpisodeFilename from './src/calculateEpisodeFilename.js'
6
+
5
7
  import readableFilters from './src/readableFilters.js'
6
8
  import excerpts from './src/excerpts.js'
7
9
  import drafts from './src/drafts.js'
@@ -11,7 +13,8 @@ export default function (eleventyConfig, options = {}) {
11
13
  eleventyConfig.addPlugin(podcastFeed, options)
12
14
  eleventyConfig.addPlugin(podcastData, options)
13
15
  eleventyConfig.addPlugin(episodeData, options)
14
- eleventyConfig.addPlugin(calculateFilenameSizeAndDuration, options)
16
+ eleventyConfig.addPlugin(calculateEpisodeSizeAndDuration, options)
17
+ eleventyConfig.addPlugin(calculateEpisodeFilename, options)
15
18
 
16
19
  // Filters
17
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-podcaster",
3
- "version": "2.0.0-alpha.2",
3
+ "version": "2.0.0-alpha.3",
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,6 +30,7 @@
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
35
  "chalk": "^5.3.0",
35
36
  "dom-serializer": "^2.0.0",
@@ -40,7 +41,9 @@
40
41
  },
41
42
  "devDependencies": {
42
43
  "ava": "^6.1.3",
44
+ "dotenv": "^17.2.1",
43
45
  "fast-xml-parser": "^4.4.0",
46
+ "mock-aws-s3-v3": "^6.0.5",
44
47
  "neostandard": "^0.11.4"
45
48
  }
46
49
  }
@@ -0,0 +1,39 @@
1
+ function findMatchingFilename (episodeData, thisEpisode) {
2
+ const filenameSeasonAndEpisodePattern =
3
+ /^.*?\b[sS](?<seasonNumber>\d+)\s*[eE](?<episodeNumber>\d+)\b.*\.mp3$/
4
+ const filenameEpisodePattern = /^.*?\b(?<episodeNumber>\d+)\b.*\.mp3$/
5
+ const { seasonNumber, episodeNumber } = thisEpisode
6
+
7
+ for (const file of Object.keys(episodeData)) {
8
+ if (seasonNumber && episodeNumber) {
9
+ const seasonAndEpisodeMatch = file.match(filenameSeasonAndEpisodePattern)
10
+ if (seasonAndEpisodeMatch) {
11
+ const matchedSeasonNumber = parseInt(seasonAndEpisodeMatch.groups.seasonNumber)
12
+ const matchedEpisodeNumber = parseInt(seasonAndEpisodeMatch.groups.episodeNumber)
13
+ if (matchedSeasonNumber === seasonNumber &&
14
+ matchedEpisodeNumber === episodeNumber) {
15
+ return file
16
+ }
17
+ }
18
+ } else if (episodeNumber) {
19
+ const episodeMatch = file.match(filenameEpisodePattern)
20
+ if (episodeMatch) {
21
+ const matchedEpisodeNumber = parseInt(episodeMatch.groups.episodeNumber)
22
+ if (matchedEpisodeNumber === episodeNumber) {
23
+ return file
24
+ }
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ export default function (eleventyConfig, _options) {
31
+ eleventyConfig.addGlobalData('eleventyComputed.episode.filename', () => {
32
+ return data => {
33
+ if (data.episode.filename) return data.episode.filename
34
+ if (!data.page.inputPath.includes('/episodePosts/')) return
35
+
36
+ return findMatchingFilename(data.episodeData, data.episode)
37
+ }
38
+ })
39
+ }
@@ -0,0 +1,204 @@
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 chalk from 'chalk'
10
+
11
+ const convertSecondsToReadableDuration = seconds =>
12
+ Duration.fromMillis(seconds * 1000)
13
+ .shiftTo('days', 'hours', 'minutes', 'seconds')
14
+ .toHuman()
15
+
16
+ const convertReadableDurationToSeconds = duration => {
17
+ const durationPattern = /^(?:(?<hours>\d+):)?(?<minutes>\d{1,2}):(?<seconds>\d{2}(?:\.\d+)?)$/
18
+
19
+ let match
20
+ if (duration?.match) {
21
+ match = duration.match(durationPattern)
22
+ }
23
+
24
+ if (match) {
25
+ const hours = isNaN(parseInt(match.groups.hours))
26
+ ? 0
27
+ : parseInt(match.groups.hours)
28
+ const minutes = parseInt(match.groups.minutes)
29
+ const seconds = parseFloat(match.groups.seconds)
30
+ return hours * 3600 + minutes * 60 + seconds
31
+ }
32
+ }
33
+
34
+ async function readEpisodeDataLocally (episodeFilesDirectory) {
35
+ const episodes = await readdir(episodeFilesDirectory)
36
+ const episodeData = {}
37
+ for (const episode of episodes) {
38
+ if (!episode.endsWith('.mp3')) continue
39
+
40
+ const episodePath = path.join(episodeFilesDirectory, episode)
41
+ const episodeSize = (await stat(episodePath)).size
42
+ const episodeMetadata = await parseFileMetadata(episodePath, { duration: true })
43
+ const episodeDuration = episodeMetadata.format.duration
44
+ episodeData[episode] = {
45
+ size: episodeSize,
46
+ duration: episodeDuration
47
+ }
48
+ }
49
+ return episodeData
50
+ }
51
+
52
+ function calculatePodcastData (episodeData) {
53
+ const episodeDataValues = Object.values(episodeData)
54
+ const numberOfEpisodes = episodeDataValues.length
55
+ const totalSize = episodeDataValues.map(x => x.size).reduce((x, y) => x + y)
56
+ const totalDuration = episodeDataValues.map(x => x.duration).reduce((x, y) => x + y)
57
+ return { numberOfEpisodes, totalSize, totalDuration }
58
+ }
59
+
60
+ function reportPodcastData (podcastData) {
61
+ const { numberOfEpisodes, totalSize, totalDuration } = podcastData
62
+ console.log(chalk.yellow(`${numberOfEpisodes} episodes; ${hr.fromBytes(totalSize)}; ${convertSecondsToReadableDuration(totalDuration)}.`))
63
+ }
64
+
65
+ async function writePodcastDataLocally (episodeData, podcastData, directories) {
66
+ const dataDir = path.join(process.cwd(), directories.data)
67
+ await writeFile(path.join(dataDir, 'episodeData.json'), JSON.stringify(episodeData, null, 2))
68
+ await writeFile(path.join(dataDir, 'podcastData.json'), JSON.stringify(podcastData, null, 2))
69
+ }
70
+
71
+ function getS3Client (options) {
72
+ if (options.s3ClientObject) return options.s3ClientObject
73
+
74
+ if (options.s3Client) {
75
+ return new S3Client({
76
+ forcePathStyle: true,
77
+ endpoint: options.s3Client.endpoint,
78
+ region: options.s3Client.region,
79
+ credentials: {
80
+ accessKeyId: options.s3Client.accessKey,
81
+ secretAccessKey: options.s3Client.secretKey
82
+ }
83
+ })
84
+ }
85
+ }
86
+
87
+ async function getObjectFromS3Bucket (s3Client, s3Bucket, key) {
88
+ const getObjectResponse = await s3Client.send(new GetObjectCommand({ Bucket: s3Bucket, Key: key }))
89
+
90
+ const chunks = []
91
+ if (typeof getObjectResponse.Body.pipe === 'function') {
92
+ // this is to cope with the behaviour of the mock, which doesn't return an iterator full of chunks
93
+ const writable = new Writable({
94
+ write (chunk, encoding, callback) {
95
+ chunks.push(chunk)
96
+ callback()
97
+ },
98
+ final (callback) {
99
+ callback()
100
+ }
101
+ })
102
+ getObjectResponse.Body.pipe(writable)
103
+ await new Promise((resolve, reject) => {
104
+ writable.on('finish', resolve)
105
+ writable.on('error', reject)
106
+ })
107
+ } else {
108
+ for await (const chunk of getObjectResponse.Body) {
109
+ chunks.push(chunk)
110
+ }
111
+ }
112
+ const buffer = Buffer.concat(chunks)
113
+ return { buffer, lastModified: getObjectResponse.LastModified }
114
+ }
115
+
116
+ async function getStoredEpisodeDataFromS3Bucket (s3Client, s3BucketName) {
117
+ try {
118
+ const { buffer, lastModified } = await getObjectFromS3Bucket(s3Client, s3BucketName, 'episodeData.json')
119
+ return { episodeData: JSON.parse(buffer.toString()), lastModified }
120
+ } catch (err) {
121
+ return { episodeData: {}, lastModified: null }
122
+ }
123
+ }
124
+
125
+ async function updateEpisodeDataFromS3Bucket (s3Client, s3Bucket) {
126
+ const storedEpisodeData = await getStoredEpisodeDataFromS3Bucket(s3Client, s3Bucket)
127
+ const storedEpisodeDataLastModifiedDate = (storedEpisodeData.lastModified)
128
+ ? DateTime.fromISO(storedEpisodeData.lastModified)
129
+ : null
130
+ const list = await s3Client.send(new ListObjectsCommand({ Bucket: s3Bucket }))
131
+ const result = { ...storedEpisodeData.episodeData }
132
+ for (const item of list.Contents ?? []) {
133
+ if (!item.Key.endsWith('.mp3')) continue
134
+
135
+ const { Key: filename, Size: size, LastModified: lastModified } = item
136
+
137
+ if (!(filename in result) ||
138
+ !('size' in result[filename]) ||
139
+ !('duration' in result[filename]) ||
140
+ !storedEpisodeDataLastModifiedDate ||
141
+ storedEpisodeDataLastModifiedDate > DateTime.fromISO(lastModified)) {
142
+ const { buffer } = await getObjectFromS3Bucket(s3Client, s3Bucket, filename)
143
+ const metadata = await parseBufferMetadata(buffer, null, { duration: true })
144
+ const duration = metadata.format.duration
145
+ result[filename] = { size, duration }
146
+ }
147
+ }
148
+ return result
149
+ }
150
+
151
+ async function writeEpisodeDataToS3Bucket (s3Client, s3Bucket, episodeData) {
152
+ await s3Client.send(new PutObjectCommand({
153
+ Bucket: s3Bucket,
154
+ Key: 'episodeData.json',
155
+ Body: JSON.stringify(episodeData, null, 2),
156
+ ContentType: 'application/json'
157
+ }))
158
+ }
159
+
160
+ export default function (eleventyConfig, options = {}) {
161
+ let firstRun = true
162
+ eleventyConfig.on('eleventy.before', async ({ directories }) => {
163
+ if (!firstRun || process.env.SKIP_EPISODE_CALCULATIONS === 'true') return
164
+ firstRun = false
165
+
166
+ const episodeFilesDirectory = path.join(directories.input, 'episodeFiles')
167
+ let episodeData
168
+ if (existsSync(episodeFilesDirectory)) {
169
+ episodeData = await readEpisodeDataLocally(episodeFilesDirectory)
170
+ } else if (options.s3ClientObject || options.s3Client) {
171
+ const s3Client = getS3Client(options)
172
+ const s3Bucket = options.s3Client.bucket
173
+ episodeData = await updateEpisodeDataFromS3Bucket(s3Client, s3Bucket)
174
+ await writeEpisodeDataToS3Bucket(s3Client, s3Bucket, episodeData)
175
+ } else {
176
+ return
177
+ }
178
+ const podcastData = calculatePodcastData(episodeData)
179
+ await writePodcastDataLocally(episodeData, podcastData, directories)
180
+ if (!eleventyConfig.quietMode) reportPodcastData(podcastData)
181
+ })
182
+
183
+ eleventyConfig.addGlobalData('eleventyComputed.episode.size', () => {
184
+ return data => {
185
+ if (data.episode.size) return data.episode.size
186
+ if (data.page.inputPath.includes('/episodePosts/') && data.episodeData) {
187
+ return data.episodeData[data.episode.filename]?.size
188
+ }
189
+ }
190
+ })
191
+
192
+ eleventyConfig.addGlobalData('eleventyComputed.episode.duration', () => {
193
+ return data => {
194
+ if (data.episode.duration) {
195
+ const convertedReadableDuration = convertReadableDurationToSeconds(data.episode.duration)
196
+ return convertedReadableDuration ?? data.episode.duration
197
+ }
198
+
199
+ if (data.page.inputPath.includes('/episodePosts/') && data.episodeData) {
200
+ return data.episodeData[data.episode.filename]?.duration
201
+ }
202
+ }
203
+ })
204
+ }
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
  }
@@ -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
- }