podcast-dl 6.1.0 → 7.1.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,26 +1,98 @@
1
- const _url = require("url");
2
- const rssParser = require("rss-parser");
3
- const { promisify } = require("util");
4
- const stream = require("stream");
5
- const path = require("path");
6
- const fs = require("fs");
7
- const got = require("got");
8
- const dayjs = require("dayjs");
9
- const { execSync } = require("child_process");
10
-
11
- const {
12
- getShouldOutputProgressIndicator,
13
- logMessage,
14
- logError,
15
- logErrorAndExit,
16
- LOG_LEVELS,
17
- } = require("./logger");
18
-
19
- const pipeline = promisify(stream.pipeline);
1
+ import rssParser from "rss-parser";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import dayjs from "dayjs";
5
+ import got from "got";
6
+ import util from "util";
7
+ import { exec } from "child_process";
8
+
9
+ import { logErrorAndExit, logMessage } from "./logger.js";
10
+ import { getArchiveFilename, getFilename } from "./naming.js";
11
+
12
+ const execWithPromise = util.promisify(exec);
13
+
20
14
  const parser = new rssParser({
21
15
  defaultRSS: 2.0,
22
16
  });
23
17
 
18
+ const getArchiveKey = ({ prefix, name }) => {
19
+ return `${prefix}-${name}`;
20
+ };
21
+
22
+ const getArchive = (archive) => {
23
+ const archivePath = path.resolve(process.cwd(), archive);
24
+
25
+ if (!fs.existsSync(archivePath)) {
26
+ return [];
27
+ }
28
+
29
+ return JSON.parse(fs.readFileSync(archivePath));
30
+ };
31
+
32
+ const writeToArchive = ({ key, archive }) => {
33
+ const archivePath = path.resolve(process.cwd(), archive);
34
+ const archiveResult = getArchive(archive);
35
+
36
+ if (!archiveResult.includes(key)) {
37
+ archiveResult.push(key);
38
+ }
39
+
40
+ fs.writeFileSync(archivePath, JSON.stringify(archiveResult, null, 4));
41
+ };
42
+
43
+ const getIsInArchive = ({ key, archive }) => {
44
+ const archiveResult = getArchive(archive);
45
+ return archiveResult.includes(key);
46
+ };
47
+
48
+ const getPossibleUrlEmbeds = (url, maxAmount = 5) => {
49
+ const fullUrl = new URL(url);
50
+ const possibleStartIndexes = [];
51
+
52
+ for (let i = 0; i < fullUrl.pathname.length; i++) {
53
+ if (fullUrl.pathname[i] === "/") {
54
+ possibleStartIndexes.push(i);
55
+ }
56
+ }
57
+
58
+ const possibleEmbedChoices = possibleStartIndexes.map((startIndex) => {
59
+ let possibleEmbed = fullUrl.pathname.slice(startIndex + 1);
60
+
61
+ if (!possibleEmbed.startsWith("http")) {
62
+ possibleEmbed = `https://${possibleEmbed}`;
63
+ }
64
+
65
+ return decodeURIComponent(possibleEmbed);
66
+ });
67
+
68
+ return possibleEmbedChoices
69
+ .slice(Math.max(possibleEmbedChoices.length - maxAmount, 0))
70
+ .reverse();
71
+ };
72
+
73
+ const getUrlEmbed = async (url) => {
74
+ const possibleUrlEmbeds = getPossibleUrlEmbeds(url);
75
+ for (const possibleUrl of possibleUrlEmbeds) {
76
+ try {
77
+ const embeddedUrl = new URL(possibleUrl);
78
+ await got(embeddedUrl.href, {
79
+ timeout: 3000,
80
+ method: "HEAD",
81
+ responseType: "json",
82
+ headers: {
83
+ accept: "*/*",
84
+ },
85
+ });
86
+
87
+ return embeddedUrl;
88
+ } catch (error) {
89
+ // do nothing
90
+ }
91
+ }
92
+
93
+ return null;
94
+ };
95
+
24
96
  const getLoopControls = ({ limit, offset, length, reverse }) => {
25
97
  if (reverse) {
26
98
  const startIndex = length - 1 - offset;
@@ -48,6 +120,9 @@ const getLoopControls = ({ limit, offset, length, reverse }) => {
48
120
  };
49
121
 
50
122
  const getItemsToDownload = ({
123
+ archive,
124
+ archiveUrl,
125
+ basePath,
51
126
  feed,
52
127
  limit,
53
128
  offset,
@@ -55,6 +130,8 @@ const getItemsToDownload = ({
55
130
  before,
56
131
  after,
57
132
  episodeRegex,
133
+ episodeTemplate,
134
+ includeEpisodeImages,
58
135
  }) => {
59
136
  const { startIndex, limitCheck, next } = getLoopControls({
60
137
  limit,
@@ -66,6 +143,8 @@ const getItemsToDownload = ({
66
143
  let i = startIndex;
67
144
  const items = [];
68
145
 
146
+ const savedArchive = archive ? getArchive(archive) : [];
147
+
69
148
  while (limitCheck(i)) {
70
149
  const { title, pubDate } = feed.items[i];
71
150
  const pubDateDay = dayjs(new Date(pubDate));
@@ -98,9 +177,59 @@ const getItemsToDownload = ({
98
177
  }
99
178
  }
100
179
 
180
+ const { url: episodeAudioUrl, ext: audioFileExt } =
181
+ getEpisodeAudioUrlAndExt(feed.items[i]);
182
+ const key = getArchiveKey({
183
+ prefix: archiveUrl,
184
+ name: getArchiveFilename({
185
+ pubDate,
186
+ name: title,
187
+ ext: audioFileExt,
188
+ }),
189
+ });
190
+
191
+ if (key && savedArchive.includes(key)) {
192
+ isValid = false;
193
+ }
194
+
101
195
  if (isValid) {
102
196
  const item = feed.items[i];
103
197
  item._originalIndex = i;
198
+ item._extra_downloads = [];
199
+
200
+ if (includeEpisodeImages) {
201
+ const episodeImageUrl = getImageUrl(item);
202
+
203
+ if (episodeImageUrl) {
204
+ const episodeImageFileExt = getUrlExt(episodeImageUrl);
205
+ const episodeImageArchiveKey = getArchiveKey({
206
+ prefix: archiveUrl,
207
+ name: getArchiveFilename({
208
+ pubDate,
209
+ name: title,
210
+ ext: episodeImageFileExt,
211
+ }),
212
+ });
213
+
214
+ if (!savedArchive.includes(episodeImageArchiveKey)) {
215
+ const episodeImageName = getFilename({
216
+ item,
217
+ feed,
218
+ url: episodeAudioUrl,
219
+ ext: episodeImageFileExt,
220
+ template: episodeTemplate,
221
+ });
222
+
223
+ const outputImagePath = path.resolve(basePath, episodeImageName);
224
+ item._extra_downloads.push({
225
+ url: episodeImageUrl,
226
+ outputPath: outputImagePath,
227
+ key: episodeImageArchiveKey,
228
+ });
229
+ }
230
+ }
231
+ }
232
+
104
233
  items.push(item);
105
234
  }
106
235
 
@@ -111,9 +240,9 @@ const getItemsToDownload = ({
111
240
  };
112
241
 
113
242
  const logFeedInfo = (feed) => {
114
- console.log(`Title: ${feed.title}`);
115
- console.log(`Description: ${feed.description}`);
116
- console.log(`Total Episodes: ${feed.items ? feed.items.length : 0}`);
243
+ logMessage(feed.title);
244
+ logMessage(feed.description);
245
+ logMessage();
117
246
  };
118
247
 
119
248
  const ITEM_LIST_FORMATS = {
@@ -148,6 +277,7 @@ const logItemsList = ({
148
277
  pubDate: item.pubDate,
149
278
  };
150
279
  });
280
+
151
281
  if (!tableData.length) {
152
282
  logErrorAndExit("No episodes found with provided criteria to list");
153
283
  }
@@ -159,46 +289,9 @@ const logItemsList = ({
159
289
  }
160
290
  };
161
291
 
162
- const logItemInfo = (item, logLevel) => {
163
- const { title, pubDate } = item;
164
-
165
- logMessage(`Title: ${title}`, logLevel);
166
- logMessage(`Publish Date: ${pubDate}`, logLevel);
167
- };
168
-
169
- const getArchiveKey = ({ prefix, name }) => {
170
- return `${prefix}-${name}`;
171
- };
172
-
173
- const writeToArchive = ({ key, archive }) => {
174
- let archiveResult = [];
175
- const archivePath = path.resolve(process.cwd(), archive);
176
-
177
- if (fs.existsSync(archivePath)) {
178
- archiveResult = JSON.parse(fs.readFileSync(archivePath));
179
- }
180
-
181
- if (!archiveResult.includes(key)) {
182
- archiveResult.push(key);
183
- }
184
-
185
- fs.writeFileSync(archivePath, JSON.stringify(archiveResult, null, 4));
186
- };
187
-
188
- const getIsInArchive = ({ key, archive }) => {
189
- const archivePath = path.resolve(process.cwd(), archive);
190
-
191
- if (!fs.existsSync(archivePath)) {
192
- return false;
193
- }
194
-
195
- const archiveResult = JSON.parse(fs.readFileSync(archivePath));
196
- return archiveResult.includes(key);
197
- };
198
-
199
292
  const writeFeedMeta = ({ outputPath, feed, key, archive, override }) => {
200
293
  if (key && archive && getIsInArchive({ key, archive })) {
201
- logMessage("Feed metadata exists in archive. Skipping write");
294
+ logMessage("Feed metadata exists in archive. Skipping write...");
202
295
  return;
203
296
  }
204
297
 
@@ -225,24 +318,35 @@ const writeFeedMeta = ({ outputPath, feed, key, archive, override }) => {
225
318
  )
226
319
  );
227
320
  } else {
228
- logMessage("Feed metadata exists locally. Skipping write");
321
+ logMessage("Feed metadata exists locally. Skipping write...");
229
322
  }
230
323
 
231
324
  if (key && archive && !getIsInArchive({ key, archive })) {
232
325
  try {
233
326
  writeToArchive({ key, archive });
234
327
  } catch (error) {
235
- logError("Error writing to archive", error);
328
+ throw new Error(`Error writing to archive: ${error.toString()}`);
236
329
  }
237
330
  }
238
331
  } catch (error) {
239
- logError("Unable to save metadata file for episode", error);
332
+ throw new Error(
333
+ `Unable to save metadata file for feed: ${error.toString()}`
334
+ );
240
335
  }
241
336
  };
242
337
 
243
- const writeItemMeta = ({ outputPath, item, key, archive, override }) => {
338
+ const writeItemMeta = ({
339
+ marker,
340
+ outputPath,
341
+ item,
342
+ key,
343
+ archive,
344
+ override,
345
+ }) => {
244
346
  if (key && archive && getIsInArchive({ key, archive })) {
245
- logMessage("Episode metadata exists in archive. Skipping write");
347
+ logMessage(
348
+ `${marker} | Episode metadata exists in archive. Skipping write...`
349
+ );
246
350
  return;
247
351
  }
248
352
 
@@ -267,23 +371,25 @@ const writeItemMeta = ({ outputPath, item, key, archive, override }) => {
267
371
  )
268
372
  );
269
373
  } else {
270
- logMessage("Episode metadata exists locally. Skipping write");
374
+ logMessage(
375
+ `${marker} | Episode metadata exists locally. Skipping write...`
376
+ );
271
377
  }
272
378
 
273
379
  if (key && archive && !getIsInArchive({ key, archive })) {
274
380
  try {
275
381
  writeToArchive({ key, archive });
276
382
  } catch (error) {
277
- logError("Error writing to archive", error);
383
+ throw new Error("Error writing to archive", error);
278
384
  }
279
385
  }
280
386
  } catch (error) {
281
- logError("Unable to save meta file for episode", error);
387
+ throw new Error("Unable to save meta file for episode", error);
282
388
  }
283
389
  };
284
390
 
285
391
  const getUrlExt = (url) => {
286
- const { pathname } = _url.parse(url);
392
+ const { pathname } = new URL(url);
287
393
 
288
394
  if (!pathname) {
289
395
  return "";
@@ -349,149 +455,8 @@ const getImageUrl = ({ image, itunes }) => {
349
455
  return null;
350
456
  };
351
457
 
352
- const BYTES_IN_MB = 1000000;
353
- const printProgress = ({ percent, total, transferred }) => {
354
- if (!getShouldOutputProgressIndicator()) {
355
- /*
356
- Non-TTY environments do not have access to `stdout.clearLine` and
357
- `stdout.cursorTo`. Skip download progress logging in these environments.
358
- */
359
- return;
360
- }
361
-
362
- let line = "downloading...";
363
- const percentRounded = (percent * 100).toFixed(2);
364
-
365
- if (transferred > 0) {
366
- /*
367
- * Got has a bug where it'll set percent to 1 when the download first starts.
368
- * Ignore percent until transfer has started.
369
- */
370
- line += ` ${percentRounded}%`;
371
-
372
- if (total) {
373
- const totalMBs = total / BYTES_IN_MB;
374
- const roundedTotalMbs = totalMBs.toFixed(2);
375
- line += ` of ${roundedTotalMbs} MB`;
376
- }
377
- }
378
-
379
- process.stdout.clearLine();
380
- process.stdout.cursorTo(0);
381
- process.stdout.write(line);
382
- };
383
-
384
- const download = async ({
385
- url,
386
- outputPath,
387
- key,
388
- archive,
389
- override,
390
- onSkip,
391
- onBeforeDownload,
392
- onAfterDownload,
393
- }) => {
394
- if (key && archive && getIsInArchive({ key, archive })) {
395
- if (onSkip) {
396
- onSkip();
397
- }
398
-
399
- logMessage("Download exists in archive. Skipping");
400
- return;
401
- }
402
-
403
- if (!override && fs.existsSync(outputPath)) {
404
- if (onSkip) {
405
- onSkip();
406
- }
407
-
408
- logMessage("Download exists locally. Skipping");
409
- return;
410
- }
411
-
412
- if (onBeforeDownload) {
413
- onBeforeDownload();
414
- }
415
-
416
- const headResponse = await got(url, {
417
- timeout: 5000,
418
- method: "HEAD",
419
- responseType: "json",
420
- headers: {
421
- accept: "*/*",
422
- },
423
- });
424
-
425
- const removeFile = () => {
426
- if (fs.existsSync(outputPath)) {
427
- fs.unlinkSync(outputPath);
428
- }
429
- };
430
-
431
- const expectedSize =
432
- headResponse &&
433
- headResponse.headers &&
434
- headResponse.headers["content-length"]
435
- ? parseInt(headResponse.headers["content-length"])
436
- : 0;
437
-
438
- if (!getShouldOutputProgressIndicator()) {
439
- logMessage(
440
- `Starting download${
441
- expectedSize
442
- ? ` of ${(expectedSize / BYTES_IN_MB).toFixed(2)} MB`
443
- : "..."
444
- }`
445
- );
446
- }
447
-
448
- try {
449
- await pipeline(
450
- got.stream(url).on("downloadProgress", (progress) => {
451
- printProgress(progress);
452
- }),
453
- fs.createWriteStream(outputPath)
454
- );
455
- } catch (error) {
456
- removeFile();
457
-
458
- throw error;
459
- } finally {
460
- if (getShouldOutputProgressIndicator()) {
461
- console.log();
462
- }
463
- }
464
-
465
- const fileSize = fs.statSync(outputPath).size;
466
-
467
- if (fileSize === 0) {
468
- removeFile();
469
- throw new Error("Unable to write to file. Suggestion: verify permissions");
470
- }
471
-
472
- if (expectedSize && !isNaN(expectedSize) && expectedSize !== fileSize) {
473
- logMessage(
474
- "File size differs from expected content length. Suggestion: verify file works as expected",
475
- LOG_LEVELS.important
476
- );
477
- logMessage(outputPath, LOG_LEVELS.important);
478
- }
479
-
480
- if (onAfterDownload) {
481
- onAfterDownload();
482
- }
483
-
484
- if (key && archive && !getIsInArchive({ key, archive })) {
485
- try {
486
- writeToArchive({ key, archive });
487
- } catch (error) {
488
- logError("Error writing to archive", error);
489
- }
490
- }
491
- };
492
-
493
458
  const getFeed = async (url) => {
494
- const { href } = _url.parse(url);
459
+ const { href } = new URL(url);
495
460
 
496
461
  let feed;
497
462
  try {
@@ -503,7 +468,7 @@ const getFeed = async (url) => {
503
468
  return feed;
504
469
  };
505
470
 
506
- const runFfmpeg = ({
471
+ const runFfmpeg = async ({
507
472
  feed,
508
473
  item,
509
474
  itemIndex,
@@ -517,8 +482,7 @@ const runFfmpeg = ({
517
482
  }
518
483
 
519
484
  if (!outputPath.endsWith(".mp3")) {
520
- logError("Not an .mp3 file. Unable to run ffmpeg.");
521
- return;
485
+ throw new Error("Not an .mp3 file. Unable to run ffmpeg.");
522
486
  }
523
487
 
524
488
  let command = `ffmpeg -loglevel quiet -i "${outputPath}"`;
@@ -570,26 +534,21 @@ const runFfmpeg = ({
570
534
  const tmpMp3Path = `${outputPath}.tmp.mp3`;
571
535
  command += ` "${tmpMp3Path}"`;
572
536
 
573
- logMessage("Running ffmpeg...");
574
-
575
537
  try {
576
- execSync(command);
538
+ await execWithPromise(command, { stdio: "ignore" });
577
539
  } catch (error) {
578
- logError("Error running ffmpeg", error);
579
-
580
540
  if (fs.existsSync(tmpMp3Path)) {
581
- logMessage("Cleaning up temporary file...");
582
541
  fs.unlinkSync(tmpMp3Path);
583
542
  }
584
543
 
585
- return;
544
+ throw error;
586
545
  }
587
546
 
588
547
  fs.unlinkSync(outputPath);
589
548
  fs.renameSync(tmpMp3Path, outputPath);
590
549
  };
591
550
 
592
- const runExec = ({ exec, outputPodcastPath, episodeFilename }) => {
551
+ const runExec = async ({ exec, outputPodcastPath, episodeFilename }) => {
593
552
  const filenameBase = episodeFilename.substring(
594
553
  0,
595
554
  episodeFilename.lastIndexOf(".")
@@ -597,23 +556,22 @@ const runExec = ({ exec, outputPodcastPath, episodeFilename }) => {
597
556
  const execCmd = exec
598
557
  .replace(/{}/g, `"${outputPodcastPath}"`)
599
558
  .replace(/{filenameBase}/g, `"${filenameBase}"`);
600
- try {
601
- execSync(execCmd, { stdio: "ignore" });
602
- } catch (error) {
603
- logError(`--exec process error: exit code ${error.status}`, error);
604
- }
559
+
560
+ await execWithPromise(execCmd, { stdio: "ignore" });
605
561
  };
606
562
 
607
- module.exports = {
608
- download,
563
+ export {
564
+ getArchive,
565
+ getIsInArchive,
609
566
  getArchiveKey,
567
+ writeToArchive,
610
568
  getEpisodeAudioUrlAndExt,
611
569
  getFeed,
612
570
  getImageUrl,
613
571
  getItemsToDownload,
614
572
  getUrlExt,
573
+ getUrlEmbed,
615
574
  logFeedInfo,
616
- logItemInfo,
617
575
  ITEM_LIST_FORMATS,
618
576
  logItemsList,
619
577
  writeFeedMeta,
package/bin/validate.js CHANGED
@@ -1,4 +1,6 @@
1
- const { logErrorAndExit } = require("./logger");
1
+ import { sync as commandExistsSync } from "command-exists";
2
+
3
+ import { logErrorAndExit } from "./logger.js";
2
4
 
3
5
  const createParseNumber = ({ min, name, required = true }) => {
4
6
  return (value) => {
@@ -24,6 +26,10 @@ const createParseNumber = ({ min, name, required = true }) => {
24
26
  };
25
27
  };
26
28
 
27
- module.exports = {
28
- createParseNumber,
29
+ const hasFfmpeg = () => {
30
+ if (!commandExistsSync("ffmpeg")) {
31
+ logErrorAndExit('option specified requires "ffmpeg" be available');
32
+ }
29
33
  };
34
+
35
+ export { createParseNumber, hasFfmpeg };
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "podcast-dl",
3
- "version": "6.1.0",
3
+ "version": "7.1.0",
4
4
  "description": "A CLI for downloading podcasts.",
5
+ "type": "module",
5
6
  "bin": "./bin/bin.js",
6
7
  "scripts": {
7
- "build": "rimraf ./binaries && npm run pkg",
8
8
  "lint": "eslint ./bin",
9
- "pkg": "npx pkg ./ --targets node16-linux-x64,node16-win-x64,node16-macos-x64 --out-path ./binaries",
10
9
  "release": "standard-version"
11
10
  },
12
11
  "lint-staged": {
@@ -27,7 +26,7 @@
27
26
  "cli"
28
27
  ],
29
28
  "engines": {
30
- "node": ">=12.16.2"
29
+ "node": ">=14.17.6"
31
30
  },
32
31
  "repository": {
33
32
  "type": "git",
@@ -48,10 +47,14 @@
48
47
  "standard-version": "^9.0.0"
49
48
  },
50
49
  "dependencies": {
50
+ "command-exists": "^1.2.9",
51
51
  "commander": "^5.1.0",
52
52
  "dayjs": "^1.8.25",
53
53
  "filenamify": "^4.1.0",
54
54
  "got": "^11.0.2",
55
- "rss-parser": "^3.7.6"
55
+ "p-limit": "^4.0.0",
56
+ "pluralize": "^8.0.0",
57
+ "rss-parser": "^3.7.6",
58
+ "throttle-debounce": "^3.0.1"
56
59
  }
57
60
  }