podcast-dl 9.0.1 → 9.0.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/bin/util.js CHANGED
@@ -1,561 +1,564 @@
1
- import rssParser from "rss-parser";
2
- import path from "path";
3
- 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 } from "./logger.js";
9
- import { getArchiveFilename, getFilename } from "./naming.js";
10
-
11
- const execWithPromise = util.promisify(exec);
12
-
13
- const getTempPath = (path) => {
14
- return `${path}.tmp`;
15
- };
16
-
17
- const getArchiveKey = ({ prefix, name }) => {
18
- return `${prefix}-${name}`;
19
- };
20
-
21
- const getPublicObject = (object, exclude = []) => {
22
- const output = {};
23
- Object.keys(object).forEach((key) => {
24
- if (!key.startsWith("_") && !exclude.includes(key) && object[key]) {
25
- output[key] = object[key];
26
- }
27
- });
28
-
29
- return output;
30
- };
31
-
32
- const getJsonFile = (filePath) => {
33
- const fullPath = path.resolve(process.cwd(), filePath);
34
-
35
- if (!fs.existsSync(fullPath)) {
36
- return null;
37
- }
38
-
39
- const data = fs.readFileSync(fullPath);
40
-
41
- if (!data) {
42
- return null;
43
- }
44
-
45
- return JSON.parse(data);
46
- };
47
-
48
- const getArchive = (archive) => {
49
- const archiveContent = getJsonFile(archive);
50
- return archiveContent === null ? [] : archiveContent;
51
- };
52
-
53
- const writeToArchive = ({ key, archive }) => {
54
- const archivePath = path.resolve(process.cwd(), archive);
55
- const archiveResult = getArchive(archive);
56
-
57
- if (!archiveResult.includes(key)) {
58
- archiveResult.push(key);
59
- }
60
-
61
- fs.writeFileSync(archivePath, JSON.stringify(archiveResult, null, 4));
62
- };
63
-
64
- const getIsInArchive = ({ key, archive }) => {
65
- const archiveResult = getArchive(archive);
66
- return archiveResult.includes(key);
67
- };
68
-
69
- const getLoopControls = ({ offset, length, reverse }) => {
70
- if (reverse) {
71
- const startIndex = length - 1 - offset;
72
- const min = -1;
73
- const shouldGo = (i) => i > min;
74
- const decrement = (i) => i - 1;
75
-
76
- return {
77
- startIndex,
78
- shouldGo,
79
- next: decrement,
80
- };
81
- }
82
-
83
- const startIndex = 0 + offset;
84
- const max = length;
85
- const shouldGo = (i) => i < max;
86
- const increment = (i) => i + 1;
87
-
88
- return {
89
- startIndex,
90
- shouldGo,
91
- next: increment,
92
- };
93
- };
94
-
95
- const getItemsToDownload = ({
96
- archive,
97
- archiveUrl,
98
- basePath,
99
- feed,
100
- limit,
101
- offset,
102
- reverse,
103
- before,
104
- after,
105
- episodeDigits,
106
- episodeRegex,
107
- episodeSourceOrder,
108
- episodeTemplate,
109
- includeEpisodeImages,
110
- }) => {
111
- const { startIndex, shouldGo, next } = getLoopControls({
112
- offset,
113
- reverse,
114
- length: feed.items.length,
115
- });
116
-
117
- let i = startIndex;
118
- const items = [];
119
-
120
- const savedArchive = archive ? getArchive(archive) : [];
121
-
122
- while (shouldGo(i)) {
123
- const { title, pubDate } = feed.items[i];
124
- const pubDateDay = dayjs(new Date(pubDate));
125
- let isValid = true;
126
-
127
- if (episodeRegex) {
128
- const generatedEpisodeRegex = new RegExp(episodeRegex);
129
- if (title && !generatedEpisodeRegex.test(title)) {
130
- isValid = false;
131
- }
132
- }
133
-
134
- if (before) {
135
- const beforeDateDay = dayjs(new Date(before));
136
- if (
137
- !pubDateDay.isSame(beforeDateDay, "day") &&
138
- !pubDateDay.isBefore(beforeDateDay, "day")
139
- ) {
140
- isValid = false;
141
- }
142
- }
143
-
144
- if (after) {
145
- const afterDateDay = dayjs(new Date(after));
146
- if (
147
- !pubDateDay.isSame(afterDateDay, "day") &&
148
- !pubDateDay.isAfter(afterDateDay, "day")
149
- ) {
150
- isValid = false;
151
- }
152
- }
153
-
154
- const { url: episodeAudioUrl, ext: audioFileExt } =
155
- getEpisodeAudioUrlAndExt(feed.items[i], episodeSourceOrder);
156
- const key = getArchiveKey({
157
- prefix: archiveUrl,
158
- name: getArchiveFilename({
159
- pubDate,
160
- name: title,
161
- ext: audioFileExt,
162
- }),
163
- });
164
-
165
- if (key && savedArchive.includes(key)) {
166
- isValid = false;
167
- }
168
-
169
- if (isValid) {
170
- const item = feed.items[i];
171
- item._originalIndex = i;
172
- item._extra_downloads = [];
173
-
174
- if (includeEpisodeImages) {
175
- const episodeImageUrl = getImageUrl(item);
176
-
177
- if (episodeImageUrl) {
178
- const episodeImageFileExt = getUrlExt(episodeImageUrl);
179
- const episodeImageArchiveKey = getArchiveKey({
180
- prefix: archiveUrl,
181
- name: getArchiveFilename({
182
- pubDate,
183
- name: title,
184
- ext: episodeImageFileExt,
185
- }),
186
- });
187
-
188
- const episodeImageName = getFilename({
189
- item,
190
- feed,
191
- url: episodeAudioUrl,
192
- ext: episodeImageFileExt,
193
- template: episodeTemplate,
194
- width: episodeDigits,
195
- });
196
-
197
- const outputImagePath = path.resolve(basePath, episodeImageName);
198
- item._extra_downloads.push({
199
- url: episodeImageUrl,
200
- outputPath: outputImagePath,
201
- key: episodeImageArchiveKey,
202
- });
203
- }
204
- }
205
-
206
- items.push(item);
207
- }
208
-
209
- i = next(i);
210
- }
211
-
212
- return limit ? items.slice(0, limit) : items;
213
- };
214
-
215
- const logFeedInfo = (feed) => {
216
- logMessage(feed.title);
217
- logMessage(feed.description);
218
- logMessage();
219
- };
220
-
221
- const ITEM_LIST_FORMATS = ["table", "json"];
222
-
223
- const logItemsList = ({
224
- type,
225
- feed,
226
- limit,
227
- offset,
228
- reverse,
229
- before,
230
- after,
231
- episodeRegex,
232
- }) => {
233
- const items = getItemsToDownload({
234
- feed,
235
- limit,
236
- offset,
237
- reverse,
238
- before,
239
- after,
240
- episodeRegex,
241
- });
242
-
243
- if (!items.length) {
244
- logErrorAndExit("No episodes found with provided criteria to list");
245
- }
246
-
247
- const isJson = type === "json";
248
-
249
- const output = items.map((item) => {
250
- const data = {
251
- episodeNum: feed.items.length - item._originalIndex,
252
- title: item.title,
253
- pubDate: item.pubDate,
254
- };
255
-
256
- return data;
257
- });
258
-
259
- if (isJson) {
260
- console.log(JSON.stringify(output));
261
- return;
262
- }
263
-
264
- console.table(output);
265
- };
266
-
267
- const writeFeedMeta = ({ outputPath, feed, key, archive, override }) => {
268
- if (key && archive && getIsInArchive({ key, archive })) {
269
- logMessage("Feed metadata exists in archive. Skipping...");
270
- return;
271
- }
272
- const output = getPublicObject(feed, ["items"]);
273
-
274
- try {
275
- if (override || !fs.existsSync(outputPath)) {
276
- fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
277
- } else {
278
- logMessage("Feed metadata exists locally. Skipping...");
279
- }
280
-
281
- if (key && archive && !getIsInArchive({ key, archive })) {
282
- try {
283
- writeToArchive({ key, archive });
284
- } catch (error) {
285
- throw new Error(`Error writing to archive: ${error.toString()}`);
286
- }
287
- }
288
- } catch (error) {
289
- throw new Error(
290
- `Unable to save metadata file for feed: ${error.toString()}`
291
- );
292
- }
293
- };
294
-
295
- const writeItemMeta = ({
296
- marker,
297
- outputPath,
298
- item,
299
- key,
300
- archive,
301
- override,
302
- }) => {
303
- if (key && archive && getIsInArchive({ key, archive })) {
304
- logMessage(`${marker} | Episode metadata exists in archive. Skipping...`);
305
- return;
306
- }
307
-
308
- const output = getPublicObject(item);
309
-
310
- try {
311
- if (override || !fs.existsSync(outputPath)) {
312
- fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
313
- } else {
314
- logMessage(`${marker} | Episode metadata exists locally. Skipping...`);
315
- }
316
-
317
- if (key && archive && !getIsInArchive({ key, archive })) {
318
- try {
319
- writeToArchive({ key, archive });
320
- } catch (error) {
321
- throw new Error("Error writing to archive", error);
322
- }
323
- }
324
- } catch (error) {
325
- throw new Error("Unable to save meta file for episode", error);
326
- }
327
- };
328
-
329
- const getUrlExt = (url) => {
330
- if (!url) {
331
- return "";
332
- }
333
-
334
- const { pathname } = new URL(url);
335
-
336
- if (!pathname) {
337
- return "";
338
- }
339
-
340
- const ext = path.extname(pathname);
341
- return ext;
342
- };
343
-
344
- const AUDIO_TYPES_TO_EXTS = {
345
- "audio/mpeg": ".mp3",
346
- "audio/mp3": ".mp3",
347
- "audio/flac": ".flac",
348
- "audio/ogg": ".ogg",
349
- "audio/vorbis": ".ogg",
350
- "audio/mp4": ".m4a",
351
- "audio/wav": ".wav",
352
- "audio/x-wav": ".wav",
353
- "audio/aac": ".aac",
354
- };
355
-
356
- const VALID_AUDIO_EXTS = [...new Set(Object.values(AUDIO_TYPES_TO_EXTS))];
357
-
358
- const getIsAudioUrl = (url) => {
359
- let ext;
360
- try {
361
- ext = getUrlExt(url);
362
- } catch (err) {
363
- return false;
364
- }
365
-
366
- if (!ext) {
367
- return false;
368
- }
369
-
370
- return VALID_AUDIO_EXTS.includes(ext);
371
- };
372
-
373
- const AUDIO_ORDER_TYPES = {
374
- enclosure: "enclosure",
375
- link: "link",
376
- };
377
-
378
- const getEpisodeAudioUrlAndExt = (
379
- { enclosure, link },
380
- order = [AUDIO_ORDER_TYPES.enclosure, AUDIO_ORDER_TYPES.link]
381
- ) => {
382
- for (const source of order) {
383
- if (source === AUDIO_ORDER_TYPES.link && link && getIsAudioUrl(link)) {
384
- return { url: link, ext: getUrlExt(link) };
385
- }
386
-
387
- if (source === AUDIO_ORDER_TYPES.enclosure && enclosure) {
388
- if (getIsAudioUrl(enclosure.url)) {
389
- return { url: enclosure.url, ext: getUrlExt(enclosure.url) };
390
- }
391
-
392
- if (enclosure.url && AUDIO_TYPES_TO_EXTS[enclosure.type]) {
393
- return { url: enclosure.url, ext: AUDIO_TYPES_TO_EXTS[enclosure.type] };
394
- }
395
- }
396
- }
397
-
398
- return { url: null, ext: null };
399
- };
400
-
401
- const getImageUrl = ({ image, itunes }) => {
402
- if (image && image.url) {
403
- return image.url;
404
- }
405
-
406
- if (image && image.link) {
407
- return image.link;
408
- }
409
-
410
- if (itunes && itunes.image) {
411
- return itunes.image;
412
- }
413
-
414
- return null;
415
- };
416
-
417
- const getFeed = async (url, parserConfig) => {
418
- const defaultConfig = {
419
- defaultRSS: 2.0,
420
- };
421
-
422
- const config = parserConfig ? getJsonFile(parserConfig) : defaultConfig;
423
-
424
- if (parserConfig && !config) {
425
- logErrorAndExit(`Unable to load parser config: ${parserConfig}`);
426
- }
427
-
428
- const parser = new rssParser(config);
429
-
430
- const { href } = new URL(url);
431
-
432
- let feed;
433
- try {
434
- feed = await parser.parseURL(href);
435
- } catch (err) {
436
- logErrorAndExit("Unable to parse RSS URL", err);
437
- }
438
-
439
- return feed;
440
- };
441
-
442
- const runFfmpeg = async ({
443
- feed,
444
- item,
445
- itemIndex,
446
- outputPath,
447
- bitrate,
448
- mono,
449
- addMp3Metadata,
450
- }) => {
451
- if (!fs.existsSync(outputPath)) {
452
- return;
453
- }
454
-
455
- if (!outputPath.endsWith(".mp3")) {
456
- throw new Error("Not an .mp3 file. Unable to run ffmpeg.");
457
- }
458
-
459
- let command = `ffmpeg -loglevel quiet -i "${outputPath}"`;
460
-
461
- if (bitrate) {
462
- command += ` -b:a ${bitrate}`;
463
- }
464
-
465
- if (mono) {
466
- command += " -ac 1";
467
- }
468
-
469
- if (addMp3Metadata) {
470
- const album = feed.title || "";
471
- const title = item.title || "";
472
- const artist =
473
- item.itunes && item.itunes.author
474
- ? item.itunes.author
475
- : item.author || "";
476
- const track =
477
- item.itunes && item.itunes.episode
478
- ? item.itunes.episode
479
- : `${feed.items.length - itemIndex}`;
480
- const date = item.pubDate
481
- ? dayjs(new Date(item.pubDate)).format("YYYY-MM-DD")
482
- : "";
483
-
484
- const metaKeysToValues = {
485
- album,
486
- artist,
487
- title,
488
- track,
489
- date,
490
- album_artist: album,
491
- };
492
-
493
- const metadataString = Object.keys(metaKeysToValues)
494
- .map((key) =>
495
- metaKeysToValues[key]
496
- ? `-metadata ${key}="${metaKeysToValues[key].replace(/"/g, '\\"')}"`
497
- : null
498
- )
499
- .filter((segment) => !!segment)
500
- .join(" ");
501
-
502
- command += ` -map_metadata 0 ${metadataString} -codec copy`;
503
- }
504
-
505
- const tmpMp3Path = `${outputPath}.tmp.mp3`;
506
- command += ` "${tmpMp3Path}"`;
507
-
508
- try {
509
- await execWithPromise(command, { stdio: "ignore" });
510
- } catch (error) {
511
- if (fs.existsSync(tmpMp3Path)) {
512
- fs.unlinkSync(tmpMp3Path);
513
- }
514
-
515
- throw error;
516
- }
517
-
518
- fs.unlinkSync(outputPath);
519
- fs.renameSync(tmpMp3Path, outputPath);
520
- };
521
-
522
- const runExec = async ({
523
- exec,
524
- basePath,
525
- outputPodcastPath,
526
- episodeFilename,
527
- }) => {
528
- const episodeFilenameBase = episodeFilename.substring(
529
- 0,
530
- episodeFilename.lastIndexOf(".")
531
- );
532
-
533
- const execCmd = exec
534
- .replace(/{{episode_path}}/g, `"${outputPodcastPath}"`)
535
- .replace(/{{episode_path_base}}/g, `"${basePath}"`)
536
- .replace(/{{episode_filename}}/g, `"${episodeFilename}"`)
537
- .replace(/{{episode_filename_base}}/g, `"${episodeFilenameBase}"`);
538
-
539
- await execWithPromise(execCmd, { stdio: "ignore" });
540
- };
541
-
542
- export {
543
- AUDIO_ORDER_TYPES,
544
- getArchive,
545
- getIsInArchive,
546
- getArchiveKey,
547
- writeToArchive,
548
- getEpisodeAudioUrlAndExt,
549
- getFeed,
550
- getImageUrl,
551
- getItemsToDownload,
552
- getTempPath,
553
- getUrlExt,
554
- logFeedInfo,
555
- ITEM_LIST_FORMATS,
556
- logItemsList,
557
- writeFeedMeta,
558
- writeItemMeta,
559
- runFfmpeg,
560
- runExec,
561
- };
1
+ import rssParser from "rss-parser";
2
+ import path from "path";
3
+ 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";
10
+
11
+ const execWithPromise = util.promisify(exec);
12
+
13
+ const getTempPath = (path) => {
14
+ return `${path}.tmp`;
15
+ };
16
+
17
+ const getArchiveKey = ({ prefix, name }) => {
18
+ return `${prefix}-${name}`;
19
+ };
20
+
21
+ const getPublicObject = (object, exclude = []) => {
22
+ const output = {};
23
+ Object.keys(object).forEach((key) => {
24
+ if (!key.startsWith("_") && !exclude.includes(key) && object[key]) {
25
+ output[key] = object[key];
26
+ }
27
+ });
28
+
29
+ return output;
30
+ };
31
+
32
+ const getJsonFile = (filePath) => {
33
+ const fullPath = path.resolve(process.cwd(), filePath);
34
+
35
+ if (!fs.existsSync(fullPath)) {
36
+ return null;
37
+ }
38
+
39
+ const data = fs.readFileSync(fullPath);
40
+
41
+ if (!data) {
42
+ return null;
43
+ }
44
+
45
+ return JSON.parse(data);
46
+ };
47
+
48
+ const getArchive = (archive) => {
49
+ const archiveContent = getJsonFile(archive);
50
+ return archiveContent === null ? [] : archiveContent;
51
+ };
52
+
53
+ const writeToArchive = ({ key, archive }) => {
54
+ const archivePath = path.resolve(process.cwd(), archive);
55
+ const archiveResult = getArchive(archive);
56
+
57
+ if (!archiveResult.includes(key)) {
58
+ archiveResult.push(key);
59
+ }
60
+
61
+ fs.writeFileSync(archivePath, JSON.stringify(archiveResult, null, 4));
62
+ };
63
+
64
+ const getIsInArchive = ({ key, archive }) => {
65
+ const archiveResult = getArchive(archive);
66
+ return archiveResult.includes(key);
67
+ };
68
+
69
+ const getLoopControls = ({ offset, length, reverse }) => {
70
+ if (reverse) {
71
+ const startIndex = length - 1 - offset;
72
+ const min = -1;
73
+ const shouldGo = (i) => i > min;
74
+ const decrement = (i) => i - 1;
75
+
76
+ return {
77
+ startIndex,
78
+ shouldGo,
79
+ next: decrement,
80
+ };
81
+ }
82
+
83
+ const startIndex = 0 + offset;
84
+ const max = length;
85
+ const shouldGo = (i) => i < max;
86
+ const increment = (i) => i + 1;
87
+
88
+ return {
89
+ startIndex,
90
+ shouldGo,
91
+ next: increment,
92
+ };
93
+ };
94
+
95
+ const getItemsToDownload = ({
96
+ archive,
97
+ archiveUrl,
98
+ basePath,
99
+ feed,
100
+ limit,
101
+ offset,
102
+ reverse,
103
+ before,
104
+ after,
105
+ episodeDigits,
106
+ episodeRegex,
107
+ episodeSourceOrder,
108
+ episodeTemplate,
109
+ includeEpisodeImages,
110
+ }) => {
111
+ const { startIndex, shouldGo, next } = getLoopControls({
112
+ offset,
113
+ reverse,
114
+ length: feed.items.length,
115
+ });
116
+
117
+ let i = startIndex;
118
+ const items = [];
119
+
120
+ const savedArchive = archive ? getArchive(archive) : [];
121
+
122
+ while (shouldGo(i)) {
123
+ const { title, pubDate } = feed.items[i];
124
+ const pubDateDay = dayjs(new Date(pubDate));
125
+ let isValid = true;
126
+
127
+ if (episodeRegex) {
128
+ const generatedEpisodeRegex = new RegExp(episodeRegex);
129
+ if (title && !generatedEpisodeRegex.test(title)) {
130
+ isValid = false;
131
+ }
132
+ }
133
+
134
+ if (before) {
135
+ const beforeDateDay = dayjs(new Date(before));
136
+ if (
137
+ !pubDateDay.isSame(beforeDateDay, "day") &&
138
+ !pubDateDay.isBefore(beforeDateDay, "day")
139
+ ) {
140
+ isValid = false;
141
+ }
142
+ }
143
+
144
+ if (after) {
145
+ const afterDateDay = dayjs(new Date(after));
146
+ if (
147
+ !pubDateDay.isSame(afterDateDay, "day") &&
148
+ !pubDateDay.isAfter(afterDateDay, "day")
149
+ ) {
150
+ isValid = false;
151
+ }
152
+ }
153
+
154
+ const { url: episodeAudioUrl, ext: audioFileExt } =
155
+ getEpisodeAudioUrlAndExt(feed.items[i], episodeSourceOrder);
156
+ const key = getArchiveKey({
157
+ prefix: archiveUrl,
158
+ name: getArchiveFilename({
159
+ pubDate,
160
+ name: title,
161
+ ext: audioFileExt,
162
+ }),
163
+ });
164
+
165
+ if (key && savedArchive.includes(key)) {
166
+ isValid = false;
167
+ }
168
+
169
+ if (isValid) {
170
+ const item = feed.items[i];
171
+ item._originalIndex = i;
172
+ item._extra_downloads = [];
173
+
174
+ if (includeEpisodeImages) {
175
+ const episodeImageUrl = getImageUrl(item);
176
+
177
+ if (episodeImageUrl) {
178
+ const episodeImageFileExt = getUrlExt(episodeImageUrl);
179
+ const episodeImageArchiveKey = getArchiveKey({
180
+ prefix: archiveUrl,
181
+ name: getArchiveFilename({
182
+ pubDate,
183
+ name: title,
184
+ ext: episodeImageFileExt,
185
+ }),
186
+ });
187
+
188
+ const episodeImageName = getItemFilename({
189
+ item,
190
+ feed,
191
+ url: episodeAudioUrl,
192
+ ext: episodeImageFileExt,
193
+ template: episodeTemplate,
194
+ width: episodeDigits,
195
+ });
196
+
197
+ const outputImagePath = path.resolve(basePath, episodeImageName);
198
+ item._extra_downloads.push({
199
+ url: episodeImageUrl,
200
+ outputPath: outputImagePath,
201
+ key: episodeImageArchiveKey,
202
+ });
203
+ }
204
+ }
205
+
206
+ items.push(item);
207
+ }
208
+
209
+ i = next(i);
210
+ }
211
+
212
+ return limit ? items.slice(0, limit) : items;
213
+ };
214
+
215
+ const logFeedInfo = (feed) => {
216
+ logMessage(feed.title);
217
+ logMessage(feed.description);
218
+ logMessage();
219
+ };
220
+
221
+ const ITEM_LIST_FORMATS = ["table", "json"];
222
+
223
+ const logItemsList = ({
224
+ type,
225
+ feed,
226
+ limit,
227
+ offset,
228
+ reverse,
229
+ before,
230
+ after,
231
+ episodeRegex,
232
+ }) => {
233
+ const items = getItemsToDownload({
234
+ feed,
235
+ limit,
236
+ offset,
237
+ reverse,
238
+ before,
239
+ after,
240
+ episodeRegex,
241
+ });
242
+
243
+ if (!items.length) {
244
+ logErrorAndExit("No episodes found with provided criteria to list");
245
+ }
246
+
247
+ const isJson = type === "json";
248
+
249
+ const output = items.map((item) => {
250
+ const data = {
251
+ episodeNum: feed.items.length - item._originalIndex,
252
+ title: item.title,
253
+ pubDate: item.pubDate,
254
+ };
255
+
256
+ return data;
257
+ });
258
+
259
+ if (isJson) {
260
+ console.log(JSON.stringify(output));
261
+ return;
262
+ }
263
+
264
+ console.table(output);
265
+ };
266
+
267
+ const writeFeedMeta = ({ outputPath, feed, key, archive, override }) => {
268
+ if (key && archive && getIsInArchive({ key, archive })) {
269
+ logMessage("Feed metadata exists in archive. Skipping...");
270
+ return;
271
+ }
272
+ const output = getPublicObject(feed, ["items"]);
273
+
274
+ try {
275
+ if (override || !fs.existsSync(outputPath)) {
276
+ fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
277
+ } else {
278
+ logMessage("Feed metadata exists locally. Skipping...");
279
+ }
280
+
281
+ if (key && archive && !getIsInArchive({ key, archive })) {
282
+ try {
283
+ writeToArchive({ key, archive });
284
+ } catch (error) {
285
+ throw new Error(`Error writing to archive: ${error.toString()}`);
286
+ }
287
+ }
288
+ } catch (error) {
289
+ throw new Error(
290
+ `Unable to save metadata file for feed: ${error.toString()}`
291
+ );
292
+ }
293
+ };
294
+
295
+ const writeItemMeta = ({
296
+ marker,
297
+ outputPath,
298
+ item,
299
+ key,
300
+ archive,
301
+ override,
302
+ }) => {
303
+ if (key && archive && getIsInArchive({ key, archive })) {
304
+ logMessage(`${marker} | Episode metadata exists in archive. Skipping...`);
305
+ return;
306
+ }
307
+
308
+ const output = getPublicObject(item);
309
+
310
+ try {
311
+ if (override || !fs.existsSync(outputPath)) {
312
+ fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
313
+ } else {
314
+ logMessage(`${marker} | Episode metadata exists locally. Skipping...`);
315
+ }
316
+
317
+ if (key && archive && !getIsInArchive({ key, archive })) {
318
+ try {
319
+ writeToArchive({ key, archive });
320
+ } catch (error) {
321
+ throw new Error("Error writing to archive", error);
322
+ }
323
+ }
324
+ } catch (error) {
325
+ throw new Error("Unable to save meta file for episode", error);
326
+ }
327
+ };
328
+
329
+ const getUrlExt = (url) => {
330
+ if (!url) {
331
+ return "";
332
+ }
333
+
334
+ const { pathname } = new URL(url);
335
+
336
+ if (!pathname) {
337
+ return "";
338
+ }
339
+
340
+ const ext = path.extname(pathname);
341
+ return ext;
342
+ };
343
+
344
+ const AUDIO_TYPES_TO_EXTS = {
345
+ "audio/mpeg": ".mp3",
346
+ "audio/mp3": ".mp3",
347
+ "audio/flac": ".flac",
348
+ "audio/ogg": ".ogg",
349
+ "audio/vorbis": ".ogg",
350
+ "audio/mp4": ".m4a",
351
+ "audio/wav": ".wav",
352
+ "audio/x-wav": ".wav",
353
+ "audio/aac": ".aac",
354
+ };
355
+
356
+ const VALID_AUDIO_EXTS = [...new Set(Object.values(AUDIO_TYPES_TO_EXTS))];
357
+
358
+ const getIsAudioUrl = (url) => {
359
+ let ext;
360
+ try {
361
+ ext = getUrlExt(url);
362
+ } catch (err) {
363
+ return false;
364
+ }
365
+
366
+ if (!ext) {
367
+ return false;
368
+ }
369
+
370
+ return VALID_AUDIO_EXTS.includes(ext);
371
+ };
372
+
373
+ const AUDIO_ORDER_TYPES = {
374
+ enclosure: "enclosure",
375
+ link: "link",
376
+ };
377
+
378
+ const getEpisodeAudioUrlAndExt = (
379
+ { enclosure, link },
380
+ order = [AUDIO_ORDER_TYPES.enclosure, AUDIO_ORDER_TYPES.link]
381
+ ) => {
382
+ for (const source of order) {
383
+ if (source === AUDIO_ORDER_TYPES.link && link && getIsAudioUrl(link)) {
384
+ return { url: link, ext: getUrlExt(link) };
385
+ }
386
+
387
+ if (source === AUDIO_ORDER_TYPES.enclosure && enclosure) {
388
+ if (getIsAudioUrl(enclosure.url)) {
389
+ return { url: enclosure.url, ext: getUrlExt(enclosure.url) };
390
+ }
391
+
392
+ if (enclosure.url && AUDIO_TYPES_TO_EXTS[enclosure.type]) {
393
+ return { url: enclosure.url, ext: AUDIO_TYPES_TO_EXTS[enclosure.type] };
394
+ }
395
+ }
396
+ }
397
+
398
+ return { url: null, ext: null };
399
+ };
400
+
401
+ const getImageUrl = ({ image, itunes }) => {
402
+ if (image?.url) {
403
+ return image.url;
404
+ }
405
+
406
+ if (image?.link) {
407
+ return image.link;
408
+ }
409
+
410
+ if (itunes?.image) {
411
+ return itunes.image;
412
+ }
413
+
414
+ return null;
415
+ };
416
+
417
+ const getFeed = async (url, parserConfig) => {
418
+ const defaultConfig = {
419
+ defaultRSS: 2.0,
420
+ };
421
+
422
+ const config = parserConfig ? getJsonFile(parserConfig) : defaultConfig;
423
+
424
+ if (parserConfig && !config) {
425
+ logErrorAndExit(`Unable to load parser config: ${parserConfig}`);
426
+ }
427
+
428
+ const parser = new rssParser(config);
429
+
430
+ const { href } = new URL(url);
431
+
432
+ let feed;
433
+ try {
434
+ feed = await parser.parseURL(href);
435
+ } catch (err) {
436
+ logErrorAndExit("Unable to parse RSS URL", err);
437
+ }
438
+
439
+ return feed;
440
+ };
441
+
442
+ const runFfmpeg = async ({
443
+ feed,
444
+ item,
445
+ itemIndex,
446
+ outputPath,
447
+ bitrate,
448
+ mono,
449
+ addMp3Metadata,
450
+ }) => {
451
+ if (!fs.existsSync(outputPath)) {
452
+ return;
453
+ }
454
+
455
+ if (!outputPath.endsWith(".mp3")) {
456
+ throw new Error("Not an .mp3 file. Unable to run ffmpeg.");
457
+ }
458
+
459
+ let command = `ffmpeg -loglevel quiet -i "${outputPath}"`;
460
+
461
+ if (bitrate) {
462
+ command += ` -b:a ${bitrate}`;
463
+ }
464
+
465
+ if (mono) {
466
+ command += " -ac 1";
467
+ }
468
+
469
+ if (addMp3Metadata) {
470
+ const album = feed.title || "";
471
+ const artist = item.itunes?.author || item.author || "";
472
+ const title = item.title || "";
473
+ const subtitle = item.itunes?.subtitle || "";
474
+ const comment = item.content || "";
475
+ const disc = item.itunes?.season || "";
476
+ const track = item.itunes?.episode || `${feed.items.length - itemIndex}`;
477
+ const episodeType = item.itunes?.episodeType || "";
478
+ const date = item.pubDate
479
+ ? dayjs(new Date(item.pubDate)).format("YYYY-MM-DD")
480
+ : "";
481
+
482
+ const metaKeysToValues = {
483
+ album,
484
+ artist,
485
+ album_artist: artist,
486
+ title,
487
+ subtitle,
488
+ comment,
489
+ disc,
490
+ track,
491
+ "episode-type": episodeType,
492
+ date,
493
+ };
494
+
495
+ const metadataString = Object.keys(metaKeysToValues)
496
+ .map((key) =>
497
+ metaKeysToValues[key]
498
+ ? `-metadata ${key}="${metaKeysToValues[key].replace(/"/g, '\\"')}"`
499
+ : null
500
+ )
501
+ .filter((segment) => !!segment)
502
+ .join(" ");
503
+
504
+ command += ` -map_metadata 0 ${metadataString} -codec copy`;
505
+ }
506
+
507
+ const tmpMp3Path = `${outputPath}.tmp.mp3`;
508
+ command += ` "${tmpMp3Path}"`;
509
+ logMessage("Running command: " + command, LOG_LEVELS.debug);
510
+
511
+ try {
512
+ await execWithPromise(command, { stdio: "ignore" });
513
+ } catch (error) {
514
+ if (fs.existsSync(tmpMp3Path)) {
515
+ fs.unlinkSync(tmpMp3Path);
516
+ }
517
+
518
+ throw error;
519
+ }
520
+
521
+ fs.unlinkSync(outputPath);
522
+ fs.renameSync(tmpMp3Path, outputPath);
523
+ };
524
+
525
+ const runExec = async ({
526
+ exec,
527
+ basePath,
528
+ outputPodcastPath,
529
+ episodeFilename,
530
+ }) => {
531
+ const episodeFilenameBase = episodeFilename.substring(
532
+ 0,
533
+ episodeFilename.lastIndexOf(".")
534
+ );
535
+
536
+ const execCmd = exec
537
+ .replace(/{{episode_path}}/g, `"${outputPodcastPath}"`)
538
+ .replace(/{{episode_path_base}}/g, `"${basePath}"`)
539
+ .replace(/{{episode_filename}}/g, `"${episodeFilename}"`)
540
+ .replace(/{{episode_filename_base}}/g, `"${episodeFilenameBase}"`);
541
+
542
+ await execWithPromise(execCmd, { stdio: "ignore" });
543
+ };
544
+
545
+ export {
546
+ AUDIO_ORDER_TYPES,
547
+ getArchive,
548
+ getIsInArchive,
549
+ getArchiveKey,
550
+ writeToArchive,
551
+ getEpisodeAudioUrlAndExt,
552
+ getFeed,
553
+ getImageUrl,
554
+ getItemsToDownload,
555
+ getTempPath,
556
+ getUrlExt,
557
+ logFeedInfo,
558
+ ITEM_LIST_FORMATS,
559
+ logItemsList,
560
+ writeFeedMeta,
561
+ writeItemMeta,
562
+ runFfmpeg,
563
+ runExec,
564
+ };