podcast-dl 10.4.0 → 11.0.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.
package/bin/util.js CHANGED
@@ -1,17 +1,11 @@
1
- import rssParser from "rss-parser";
2
- import path from "path";
3
1
  import fs from "fs";
4
- import dayjs from "dayjs";
5
- import util from "util";
6
- import { exec } from "child_process";
7
-
8
- import { logErrorAndExit, logMessage, LOG_LEVELS } from "./logger.js";
9
- import { getArchiveFilename, getItemFilename } from "./naming.js";
2
+ import path from "path";
3
+ import rssParser from "rss-parser";
4
+ import { logErrorAndExit, logMessage } from "./logger.js";
10
5
 
11
- const execWithPromise = util.promisify(exec);
12
6
  const isWin = process.platform === "win32";
13
7
 
14
- const defaultRssParserConfig = {
8
+ export const defaultRssParserConfig = {
15
9
  defaultRSS: 2.0,
16
10
  headers: {
17
11
  Accept: "*/*",
@@ -27,12 +21,12 @@ const defaultRssParserConfig = {
27
21
  Additionally, @see https://www.robvanderwoude.com/escapechars.php for why
28
22
  we avoid trying to escape complex sequences in Windows.
29
23
  */
30
- const escapeArgForShell = (arg) => {
24
+ export const escapeArgForShell = (arg) => {
31
25
  let result = arg;
32
26
 
33
27
  if (/[^A-Za-z0-9_/:=-]/.test(result)) {
34
28
  if (isWin) {
35
- return null;
29
+ return `"${result}"`;
36
30
  } else {
37
31
  result = "'" + result.replace(/'/g, "'\\''") + "'";
38
32
  result = result
@@ -44,11 +38,11 @@ const escapeArgForShell = (arg) => {
44
38
  return result;
45
39
  };
46
40
 
47
- const getTempPath = (path) => {
41
+ export const getTempPath = (path) => {
48
42
  return `${path}.tmp`;
49
43
  };
50
44
 
51
- const prepareOutputPath = (outputPath) => {
45
+ export const prepareOutputPath = (outputPath) => {
52
46
  const outputPathSegments = outputPath.split(path.sep);
53
47
  outputPathSegments.pop();
54
48
 
@@ -59,11 +53,7 @@ const prepareOutputPath = (outputPath) => {
59
53
  }
60
54
  };
61
55
 
62
- const getArchiveKey = ({ prefix, name }) => {
63
- return `${prefix}-${name}`;
64
- };
65
-
66
- const getPublicObject = (object, exclude = []) => {
56
+ export const getPublicObject = (object, exclude = []) => {
67
57
  const output = {};
68
58
  Object.keys(object).forEach((key) => {
69
59
  if (!key.startsWith("_") && !exclude.includes(key) && object[key]) {
@@ -74,7 +64,7 @@ const getPublicObject = (object, exclude = []) => {
74
64
  return output;
75
65
  };
76
66
 
77
- const getFileString = (filePath) => {
67
+ export const getFileString = (filePath) => {
78
68
  const fullPath = path.resolve(process.cwd(), filePath);
79
69
 
80
70
  if (!fs.existsSync(fullPath)) {
@@ -90,7 +80,7 @@ const getFileString = (filePath) => {
90
80
  return data;
91
81
  };
92
82
 
93
- const getJsonFile = (filePath) => {
83
+ export const getJsonFile = (filePath) => {
94
84
  const fullPath = path.resolve(process.cwd(), filePath);
95
85
 
96
86
  if (!fs.existsSync(fullPath)) {
@@ -106,28 +96,7 @@ const getJsonFile = (filePath) => {
106
96
  return JSON.parse(data);
107
97
  };
108
98
 
109
- const getArchive = (archive) => {
110
- const archiveContent = getJsonFile(archive);
111
- return archiveContent === null ? [] : archiveContent;
112
- };
113
-
114
- const writeToArchive = ({ key, archive }) => {
115
- const archivePath = path.resolve(process.cwd(), archive);
116
- const archiveResult = getArchive(archive);
117
-
118
- if (!archiveResult.includes(key)) {
119
- archiveResult.push(key);
120
- }
121
-
122
- fs.writeFileSync(archivePath, JSON.stringify(archiveResult, null, 4));
123
- };
124
-
125
- const getIsInArchive = ({ key, archive }) => {
126
- const archiveResult = getArchive(archive);
127
- return archiveResult.includes(key);
128
- };
129
-
130
- const getLoopControls = ({ offset, length, reverse }) => {
99
+ export const getLoopControls = ({ offset, length, reverse }) => {
131
100
  if (reverse) {
132
101
  const startIndex = length - 1 - offset;
133
102
  const min = -1;
@@ -153,299 +122,13 @@ const getLoopControls = ({ offset, length, reverse }) => {
153
122
  };
154
123
  };
155
124
 
156
- const getItemsToDownload = ({
157
- archive,
158
- archivePrefix,
159
- basePath,
160
- feed,
161
- limit,
162
- offset,
163
- reverse,
164
- before,
165
- after,
166
- episodeDigits,
167
- episodeNumOffset,
168
- episodeRegex,
169
- episodeRegexExclude,
170
- episodeSourceOrder,
171
- episodeTemplate,
172
- episodeCustomTemplateOptions,
173
- includeEpisodeImages,
174
- includeEpisodeTranscripts,
175
- episodeTranscriptTypes,
176
- }) => {
177
- const { startIndex, shouldGo, next } = getLoopControls({
178
- offset,
179
- reverse,
180
- length: feed.items.length,
181
- });
182
-
183
- let i = startIndex;
184
- const items = [];
185
-
186
- const savedArchive = archive ? getArchive(archive) : [];
187
-
188
- while (shouldGo(i)) {
189
- const { title, pubDate } = feed.items[i];
190
- const pubDateDay = dayjs(new Date(pubDate));
191
- let isValid = true;
192
-
193
- if (episodeRegex) {
194
- const generatedEpisodeRegex = new RegExp(episodeRegex);
195
- if (title && !generatedEpisodeRegex.test(title)) {
196
- isValid = false;
197
- }
198
- }
199
-
200
- if (episodeRegexExclude) {
201
- const generatedEpisodeRegexExclude = new RegExp(episodeRegexExclude);
202
- if (title && generatedEpisodeRegexExclude.test(title)) {
203
- isValid = false;
204
- }
205
- }
206
-
207
- if (before) {
208
- const beforeDateDay = dayjs(new Date(before));
209
- if (
210
- !pubDateDay.isSame(beforeDateDay, "day") &&
211
- !pubDateDay.isBefore(beforeDateDay, "day")
212
- ) {
213
- isValid = false;
214
- }
215
- }
216
-
217
- if (after) {
218
- const afterDateDay = dayjs(new Date(after));
219
- if (
220
- !pubDateDay.isSame(afterDateDay, "day") &&
221
- !pubDateDay.isAfter(afterDateDay, "day")
222
- ) {
223
- isValid = false;
224
- }
225
- }
226
-
227
- const { url: episodeAudioUrl, ext: audioFileExt } =
228
- getEpisodeAudioUrlAndExt(feed.items[i], episodeSourceOrder);
229
- const key = getArchiveKey({
230
- prefix: archivePrefix,
231
- name: getArchiveFilename({
232
- pubDate,
233
- name: title,
234
- ext: audioFileExt,
235
- }),
236
- });
237
-
238
- if (key && savedArchive.includes(key)) {
239
- isValid = false;
240
- }
241
-
242
- if (isValid) {
243
- const item = feed.items[i];
244
- item._originalIndex = i;
245
- item._extra_downloads = [];
246
-
247
- if (includeEpisodeImages) {
248
- const episodeImageUrl = getImageUrl(item);
249
-
250
- if (episodeImageUrl) {
251
- const episodeImageFileExt = getUrlExt(episodeImageUrl);
252
- const episodeImageArchiveKey = getArchiveKey({
253
- prefix: archivePrefix,
254
- name: getArchiveFilename({
255
- pubDate,
256
- name: title,
257
- ext: episodeImageFileExt,
258
- }),
259
- });
260
-
261
- const episodeImageName = getItemFilename({
262
- item,
263
- feed,
264
- url: episodeAudioUrl,
265
- ext: episodeImageFileExt,
266
- template: episodeTemplate,
267
- customTemplateOptions: episodeCustomTemplateOptions,
268
- width: episodeDigits,
269
- offset: episodeNumOffset,
270
- });
271
-
272
- const outputImagePath = path.resolve(basePath, episodeImageName);
273
- item._extra_downloads.push({
274
- url: episodeImageUrl,
275
- outputPath: outputImagePath,
276
- key: episodeImageArchiveKey,
277
- });
278
- }
279
- }
280
-
281
- if (includeEpisodeTranscripts) {
282
- const episodeTranscriptUrl = getTranscriptUrl(
283
- item,
284
- episodeTranscriptTypes
285
- );
286
-
287
- if (episodeTranscriptUrl) {
288
- const episodeTranscriptFileExt = getUrlExt(episodeTranscriptUrl);
289
- const episodeTranscriptArchiveKey = getArchiveKey({
290
- prefix: archivePrefix,
291
- name: getArchiveFilename({
292
- pubDate,
293
- name: title,
294
- ext: episodeTranscriptFileExt,
295
- }),
296
- });
297
-
298
- const episodeTranscriptName = getItemFilename({
299
- item,
300
- feed,
301
- url: episodeAudioUrl,
302
- ext: episodeTranscriptFileExt,
303
- template: episodeTemplate,
304
- width: episodeDigits,
305
- offset: episodeNumOffset,
306
- });
307
-
308
- const outputTranscriptPath = path.resolve(
309
- basePath,
310
- episodeTranscriptName
311
- );
312
-
313
- item._extra_downloads.push({
314
- url: episodeTranscriptUrl,
315
- outputPath: outputTranscriptPath,
316
- key: episodeTranscriptArchiveKey,
317
- });
318
- }
319
- }
320
-
321
- items.push(item);
322
- }
323
-
324
- i = next(i);
325
- }
326
-
327
- return limit ? items.slice(0, limit) : items;
328
- };
329
-
330
- const logFeedInfo = (feed) => {
125
+ export const logFeedInfo = (feed) => {
331
126
  logMessage(feed.title);
332
127
  logMessage(feed.description);
333
128
  logMessage();
334
129
  };
335
130
 
336
- const ITEM_LIST_FORMATS = ["table", "json"];
337
-
338
- const logItemsList = ({
339
- type,
340
- feed,
341
- limit,
342
- offset,
343
- reverse,
344
- before,
345
- after,
346
- episodeRegex,
347
- episodeRegexExclude,
348
- }) => {
349
- const items = getItemsToDownload({
350
- feed,
351
- limit,
352
- offset,
353
- reverse,
354
- before,
355
- after,
356
- episodeRegex,
357
- episodeRegexExclude,
358
- });
359
-
360
- if (!items.length) {
361
- logErrorAndExit("No episodes found with provided criteria to list");
362
- }
363
-
364
- const isJson = type === "json";
365
-
366
- const output = items.map((item) => {
367
- const data = {
368
- episodeNum: feed.items.length - item._originalIndex,
369
- title: item.title,
370
- pubDate: item.pubDate,
371
- };
372
-
373
- return data;
374
- });
375
-
376
- if (isJson) {
377
- // eslint-disable-next-line no-console
378
- console.log(JSON.stringify(output));
379
- return;
380
- }
381
-
382
- // eslint-disable-next-line no-console
383
- console.table(output);
384
- };
385
-
386
- const writeFeedMeta = ({ outputPath, feed, key, archive, override }) => {
387
- if (key && archive && getIsInArchive({ key, archive })) {
388
- logMessage("Feed metadata exists in archive. Skipping...");
389
- return;
390
- }
391
- const output = getPublicObject(feed, ["items"]);
392
-
393
- try {
394
- if (override || !fs.existsSync(outputPath)) {
395
- fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
396
- } else {
397
- logMessage("Feed metadata exists locally. Skipping...");
398
- }
399
-
400
- if (key && archive && !getIsInArchive({ key, archive })) {
401
- try {
402
- writeToArchive({ key, archive });
403
- } catch (error) {
404
- throw new Error(`Error writing to archive: ${error.toString()}`);
405
- }
406
- }
407
- } catch (error) {
408
- throw new Error(
409
- `Unable to save metadata file for feed: ${error.toString()}`
410
- );
411
- }
412
- };
413
-
414
- const writeItemMeta = ({
415
- marker,
416
- outputPath,
417
- item,
418
- key,
419
- archive,
420
- override,
421
- }) => {
422
- if (key && archive && getIsInArchive({ key, archive })) {
423
- logMessage(`${marker} | Episode metadata exists in archive. Skipping...`);
424
- return;
425
- }
426
-
427
- const output = getPublicObject(item);
428
-
429
- try {
430
- if (override || !fs.existsSync(outputPath)) {
431
- fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
432
- } else {
433
- logMessage(`${marker} | Episode metadata exists locally. Skipping...`);
434
- }
435
-
436
- if (key && archive && !getIsInArchive({ key, archive })) {
437
- try {
438
- writeToArchive({ key, archive });
439
- } catch (error) {
440
- throw new Error("Error writing to archive", error);
441
- }
442
- }
443
- } catch (error) {
444
- throw new Error("Unable to save meta file for episode", error);
445
- }
446
- };
447
-
448
- const getUrlExt = (url) => {
131
+ export const getUrlExt = (url) => {
449
132
  if (!url) {
450
133
  return "";
451
134
  }
@@ -460,7 +143,7 @@ const getUrlExt = (url) => {
460
143
  return ext;
461
144
  };
462
145
 
463
- const AUDIO_TYPES_TO_EXTS = {
146
+ export const AUDIO_TYPES_TO_EXTS = {
464
147
  "audio/aac": ".aac",
465
148
  "audio/flac": ".flac",
466
149
  "audio/mp3": ".mp3",
@@ -477,9 +160,11 @@ const AUDIO_TYPES_TO_EXTS = {
477
160
  "video/x-m4v": ".m4v",
478
161
  };
479
162
 
480
- const VALID_AUDIO_EXTS = [...new Set(Object.values(AUDIO_TYPES_TO_EXTS))];
163
+ export const VALID_AUDIO_EXTS = [
164
+ ...new Set(Object.values(AUDIO_TYPES_TO_EXTS)),
165
+ ];
481
166
 
482
- const getIsAudioUrl = (url) => {
167
+ export const getIsAudioUrl = (url) => {
483
168
  let ext;
484
169
  try {
485
170
  ext = getUrlExt(url);
@@ -494,12 +179,12 @@ const getIsAudioUrl = (url) => {
494
179
  return VALID_AUDIO_EXTS.includes(ext);
495
180
  };
496
181
 
497
- const AUDIO_ORDER_TYPES = {
182
+ export const AUDIO_ORDER_TYPES = {
498
183
  enclosure: "enclosure",
499
184
  link: "link",
500
185
  };
501
186
 
502
- const getEpisodeAudioUrlAndExt = (
187
+ export const getEpisodeAudioUrlAndExt = (
503
188
  { enclosure, link },
504
189
  order = [AUDIO_ORDER_TYPES.enclosure, AUDIO_ORDER_TYPES.link]
505
190
  ) => {
@@ -522,7 +207,7 @@ const getEpisodeAudioUrlAndExt = (
522
207
  return { url: null, ext: null };
523
208
  };
524
209
 
525
- const getImageUrl = ({ image, itunes }) => {
210
+ export const getImageUrl = ({ image, itunes }) => {
526
211
  if (image?.url) {
527
212
  return image.url;
528
213
  }
@@ -549,7 +234,7 @@ export const TRANSCRIPT_TYPES = {
549
234
  };
550
235
 
551
236
  // @see https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript
552
- const getTranscriptUrl = (item, transcriptTypes = []) => {
237
+ export const getTranscriptUrl = (item, transcriptTypes = []) => {
553
238
  if (!item.podcastTranscripts?.length) {
554
239
  return null;
555
240
  }
@@ -568,7 +253,7 @@ const getTranscriptUrl = (item, transcriptTypes = []) => {
568
253
  return null;
569
254
  };
570
255
 
571
- const getFileFeed = async (filePath, parserConfig) => {
256
+ export const getFileFeed = async (filePath, parserConfig) => {
572
257
  const config = parserConfig
573
258
  ? getJsonFile(parserConfig)
574
259
  : defaultRssParserConfig;
@@ -590,7 +275,7 @@ const getFileFeed = async (filePath, parserConfig) => {
590
275
  return feed;
591
276
  };
592
277
 
593
- const getUrlFeed = async (url, parserConfig) => {
278
+ export const getUrlFeed = async (url, parserConfig) => {
594
279
  const config = parserConfig
595
280
  ? getJsonFile(parserConfig)
596
281
  : defaultRssParserConfig;
@@ -612,132 +297,3 @@ const getUrlFeed = async (url, parserConfig) => {
612
297
 
613
298
  return feed;
614
299
  };
615
-
616
- const runFfmpeg = async ({
617
- feed,
618
- item,
619
- itemIndex,
620
- outputPath,
621
- bitrate,
622
- mono,
623
- addMp3Metadata,
624
- ext,
625
- }) => {
626
- if (!fs.existsSync(outputPath)) {
627
- return;
628
- }
629
-
630
- let command = `ffmpeg -loglevel quiet -i "${outputPath}"`;
631
-
632
- if (bitrate) {
633
- command += ` -b:a ${bitrate}`;
634
- }
635
-
636
- if (mono) {
637
- command += " -ac 1";
638
- }
639
-
640
- if (addMp3Metadata) {
641
- const album = feed.title || "";
642
- const artist = item.itunes?.author || item.author || "";
643
- const title = item.title || "";
644
- const subtitle = item.itunes?.subtitle || "";
645
- const comment = item.content || "";
646
- const disc = item.itunes?.season || "";
647
- const track = item.itunes?.episode || `${feed.items.length - itemIndex}`;
648
- const episodeType = item.itunes?.episodeType || "";
649
- const date = item.pubDate
650
- ? dayjs(new Date(item.pubDate)).format("YYYY-MM-DD")
651
- : "";
652
-
653
- const metaKeysToValues = {
654
- album,
655
- artist,
656
- album_artist: artist,
657
- title,
658
- subtitle,
659
- comment,
660
- disc,
661
- track,
662
- "episode-type": episodeType,
663
- date,
664
- };
665
-
666
- const metadataString = Object.keys(metaKeysToValues)
667
- .map((key) => {
668
- if (!metaKeysToValues[key]) {
669
- return null;
670
- }
671
-
672
- const argValue = escapeArgForShell(metaKeysToValues[key]);
673
-
674
- return argValue ? `-metadata ${key}=${argValue}` : null;
675
- })
676
- .filter((segment) => !!segment)
677
- .join(" ");
678
-
679
- command += ` -map_metadata 0 ${metadataString} -codec copy`;
680
- }
681
-
682
- const tmpMp3Path = `${outputPath}.tmp${ext}`;
683
- command += ` "${tmpMp3Path}"`;
684
- logMessage("Running command: " + command, LOG_LEVELS.debug);
685
-
686
- try {
687
- await execWithPromise(command, { stdio: "ignore" });
688
- } catch (error) {
689
- if (fs.existsSync(tmpMp3Path)) {
690
- fs.unlinkSync(tmpMp3Path);
691
- }
692
-
693
- throw error;
694
- }
695
-
696
- fs.unlinkSync(outputPath);
697
- fs.renameSync(tmpMp3Path, outputPath);
698
- };
699
-
700
- const runExec = async ({
701
- exec,
702
- basePath,
703
- outputPodcastPath,
704
- episodeFilename,
705
- episodeAudioUrl,
706
- }) => {
707
- const episodeFilenameBase = episodeFilename.substring(
708
- 0,
709
- episodeFilename.lastIndexOf(".")
710
- );
711
-
712
- const execCmd = exec
713
- .replace(/{{episode_path}}/g, `"${outputPodcastPath}"`)
714
- .replace(/{{episode_path_base}}/g, `"${basePath}"`)
715
- .replace(/{{episode_filename}}/g, `"${episodeFilename}"`)
716
- .replace(/{{episode_filename_base}}/g, `"${episodeFilenameBase}"`)
717
- .replace(/{{url}}/g, `"${episodeAudioUrl}"`);
718
-
719
- await execWithPromise(execCmd, { stdio: "ignore" });
720
- };
721
-
722
- export {
723
- AUDIO_ORDER_TYPES,
724
- getArchive,
725
- getIsInArchive,
726
- getArchiveKey,
727
- writeToArchive,
728
- getEpisodeAudioUrlAndExt,
729
- getFileFeed,
730
- getImageUrl,
731
- getItemsToDownload,
732
- getTempPath,
733
- getUrlExt,
734
- getUrlFeed,
735
- logFeedInfo,
736
- ITEM_LIST_FORMATS,
737
- logItemsList,
738
- prepareOutputPath,
739
- writeFeedMeta,
740
- writeItemMeta,
741
- runFfmpeg,
742
- runExec,
743
- };
package/bin/validate.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import { sync as commandExistsSync } from "command-exists";
2
-
3
2
  import { logErrorAndExit } from "./logger.js";
4
3
 
5
- const createParseNumber = ({ min, max, name, required = true }) => {
4
+ export const createParseNumber = ({ min, max, name, required = true }) => {
6
5
  return (value) => {
7
6
  if (!value && !required) {
8
7
  return undefined;
@@ -33,10 +32,8 @@ const createParseNumber = ({ min, max, name, required = true }) => {
33
32
  };
34
33
  };
35
34
 
36
- const hasFfmpeg = () => {
35
+ export const hasFfmpeg = () => {
37
36
  if (!commandExistsSync("ffmpeg")) {
38
37
  logErrorAndExit('option specified requires "ffmpeg" be available');
39
38
  }
40
39
  };
41
-
42
- export { createParseNumber, hasFfmpeg };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "podcast-dl",
3
- "version": "10.4.0",
3
+ "version": "11.0.0",
4
4
  "description": "A CLI for downloading podcasts.",
5
5
  "type": "module",
6
6
  "bin": "./bin/bin.js",
@@ -26,7 +26,7 @@
26
26
  "cli"
27
27
  ],
28
28
  "engines": {
29
- "node": ">=18.17.0"
29
+ "node": ">=22.16.0"
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
@@ -38,11 +38,11 @@
38
38
  "author": "Joshua Pohl",
39
39
  "license": "MIT",
40
40
  "devDependencies": {
41
+ "@yao-pkg/pkg": "^5.8.0",
41
42
  "eslint": "^6.8.0",
42
43
  "eslint-config-prettier": "^6.11.0",
43
44
  "husky": "^4.2.5",
44
45
  "lint-staged": "^10.1.7",
45
- "pkg": "^5.8.0",
46
46
  "prettier": "2.3.2",
47
47
  "rimraf": "^3.0.2",
48
48
  "webpack": "^5.75.0"