podcast-dl 11.5.1 → 11.6.1
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/README.md +5 -4
- package/bin/async.js +10 -8
- package/bin/bin.js +8 -3
- package/bin/commander.js +23 -7
- package/bin/ffmpeg.js +47 -18
- package/bin/items.js +247 -247
- package/bin/util.js +10 -0
- package/lib/rss-parser/README.md +3 -0
- package/lib/rss-parser/fields.js +66 -0
- package/lib/rss-parser/parser.js +387 -0
- package/lib/rss-parser/utils.js +101 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -49,11 +49,12 @@ Type values surrounded in square brackets (`[]`) can be used as boolean options
|
|
|
49
49
|
| --episode-source-order | String | false | Attempted order to extract episode audio URL from RSS feed. Default is `"enclosure,link"`. |
|
|
50
50
|
| --episode-transcript-types | String | false | List of allowed transcript types in preferred order. Default is "application/json,application/x-subrip,application/srr,application/srt,text/vtt,text/html,text/plain". |
|
|
51
51
|
| --season | Number | false | Only download episodes from specified season. Note: this will only work if the RSS feed includes the `itunes:season` tag on episodes. |
|
|
52
|
-
| --
|
|
53
|
-
| --
|
|
54
|
-
| --
|
|
52
|
+
| --embed-metadata | | false | Add metadata to episode files. (**ffmpeg required**) |
|
|
53
|
+
| --audio-format | String | false | Convert audio to format: mp3, m4a, aac, opus, ogg, flac, wav. (**ffmpeg required**) |
|
|
54
|
+
| --adjust-bitrate | String (e.g. "48k") | false | Adjust bitrate of episodes. (**ffmpeg required**) |
|
|
55
|
+
| --mono | | false | Force episodes into mono. (**ffmpeg required**) |
|
|
55
56
|
| --override | | false | Override local files on collision. |
|
|
56
|
-
| --always-postprocess | | false | Always run additional tasks on the file regardless if the file already exists. This includes `--
|
|
57
|
+
| --always-postprocess | | false | Always run additional tasks on the file regardless if the file already exists. This includes `--embed-metadata`, `--audio-format`, `--adjust-bitrate`, `--mono`, and `--exec`. |
|
|
57
58
|
| --reverse | | false | Reverse download direction and start at last RSS item. |
|
|
58
59
|
| --info | | false | Print retrieved podcast info instead of downloading. |
|
|
59
60
|
| --list | [String] | false | Print episode list instead of downloading. Defaults to `"table"` when used as a boolean option. `"json"` is also supported. |
|
package/bin/async.js
CHANGED
|
@@ -182,12 +182,13 @@ export const download = async (options) => {
|
|
|
182
182
|
};
|
|
183
183
|
|
|
184
184
|
export const downloadItemsAsync = async ({
|
|
185
|
-
addMp3MetadataFlag,
|
|
186
185
|
archive,
|
|
187
186
|
archivePrefix,
|
|
188
187
|
attempts,
|
|
188
|
+
audioFormat,
|
|
189
189
|
basePath,
|
|
190
190
|
bitrate,
|
|
191
|
+
embedMetadataFlag,
|
|
191
192
|
episodeTemplate,
|
|
192
193
|
episodeCustomTemplateOptions,
|
|
193
194
|
episodeDigits,
|
|
@@ -313,20 +314,21 @@ export const downloadItemsAsync = async ({
|
|
|
313
314
|
const hasEpisodeImage =
|
|
314
315
|
item._episodeImage && fs.existsSync(item._episodeImage.outputPath);
|
|
315
316
|
|
|
316
|
-
if (
|
|
317
|
+
if (embedMetadataFlag || bitrate || mono || audioFormat) {
|
|
317
318
|
logMessage("Running ffmpeg...");
|
|
318
319
|
await runFfmpeg({
|
|
319
|
-
|
|
320
|
-
item,
|
|
320
|
+
audioFormat,
|
|
321
321
|
bitrate,
|
|
322
|
-
|
|
323
|
-
itemIndex: item._originalIndex,
|
|
324
|
-
outputPath: finalEpisodePath,
|
|
322
|
+
embedMetadata: embedMetadataFlag,
|
|
325
323
|
episodeImageOutputPath: hasEpisodeImage
|
|
326
324
|
? item._episodeImage.outputPath
|
|
327
325
|
: undefined,
|
|
328
|
-
addMp3Metadata: addMp3MetadataFlag,
|
|
329
326
|
ext: audioFileExt,
|
|
327
|
+
feed,
|
|
328
|
+
item,
|
|
329
|
+
itemIndex: item._originalIndex,
|
|
330
|
+
mono,
|
|
331
|
+
outputPath: finalEpisodePath,
|
|
330
332
|
});
|
|
331
333
|
}
|
|
332
334
|
|
package/bin/bin.js
CHANGED
|
@@ -32,6 +32,7 @@ const {
|
|
|
32
32
|
after,
|
|
33
33
|
alwaysPostprocess,
|
|
34
34
|
attempts,
|
|
35
|
+
audioFormat,
|
|
35
36
|
before,
|
|
36
37
|
episodeDigits,
|
|
37
38
|
episodeNumOffset,
|
|
@@ -60,12 +61,15 @@ const {
|
|
|
60
61
|
threads,
|
|
61
62
|
url,
|
|
62
63
|
userAgent,
|
|
63
|
-
|
|
64
|
+
embedMetadata,
|
|
65
|
+
addMp3Metadata,
|
|
64
66
|
adjustBitrate: bitrate,
|
|
65
67
|
season,
|
|
66
68
|
trustExt,
|
|
67
69
|
} = opts;
|
|
68
70
|
|
|
71
|
+
const embedMetadataFlag = embedMetadata || addMp3Metadata;
|
|
72
|
+
|
|
69
73
|
let { archive } = opts;
|
|
70
74
|
|
|
71
75
|
const main = async () => {
|
|
@@ -209,7 +213,7 @@ const main = async () => {
|
|
|
209
213
|
const targetItems = getItemsToDownload({
|
|
210
214
|
archive,
|
|
211
215
|
archivePrefix,
|
|
212
|
-
|
|
216
|
+
embedMetadataFlag,
|
|
213
217
|
basePath,
|
|
214
218
|
feed,
|
|
215
219
|
limit,
|
|
@@ -239,12 +243,13 @@ const main = async () => {
|
|
|
239
243
|
);
|
|
240
244
|
|
|
241
245
|
const { numEpisodesDownloaded, hasErrors } = await downloadItemsAsync({
|
|
242
|
-
addMp3MetadataFlag,
|
|
243
246
|
archive,
|
|
244
247
|
archivePrefix,
|
|
245
248
|
attempts,
|
|
249
|
+
audioFormat,
|
|
246
250
|
basePath,
|
|
247
251
|
bitrate,
|
|
252
|
+
embedMetadataFlag,
|
|
248
253
|
episodeTemplate,
|
|
249
254
|
episodeCustomTemplateOptions,
|
|
250
255
|
episodeDigits,
|
package/bin/commander.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ITEM_LIST_FORMATS } from "./items.js";
|
|
2
2
|
import { logErrorAndExit } from "./logger.js";
|
|
3
|
-
import { AUDIO_ORDER_TYPES, TRANSCRIPT_TYPES } from "./util.js";
|
|
3
|
+
import { AUDIO_FORMATS, AUDIO_ORDER_TYPES, TRANSCRIPT_TYPES } from "./util.js";
|
|
4
4
|
import { createParseNumber, hasFfmpeg } from "./validate.js";
|
|
5
5
|
|
|
6
6
|
export const setupCommander = (program) => {
|
|
@@ -126,20 +126,36 @@ export const setupCommander = (program) => {
|
|
|
126
126
|
"download episodes only before this date (inclusive)"
|
|
127
127
|
)
|
|
128
128
|
.option(
|
|
129
|
-
"--
|
|
130
|
-
"
|
|
129
|
+
"--embed-metadata",
|
|
130
|
+
"add metadata to episode files using ffmpeg",
|
|
131
131
|
hasFfmpeg
|
|
132
132
|
)
|
|
133
133
|
.option(
|
|
134
|
-
"--
|
|
135
|
-
"
|
|
134
|
+
"--add-mp3-metadata",
|
|
135
|
+
"deprecated: use --embed-metadata instead",
|
|
136
136
|
hasFfmpeg
|
|
137
137
|
)
|
|
138
138
|
.option(
|
|
139
|
-
"--
|
|
140
|
-
"
|
|
139
|
+
"--audio-format <string>",
|
|
140
|
+
"convert audio to format (mp3, m4a, aac, opus, ogg, flac, wav)",
|
|
141
|
+
(value) => {
|
|
142
|
+
if (!AUDIO_FORMATS[value]) {
|
|
143
|
+
logErrorAndExit(
|
|
144
|
+
`Invalid audio format: ${value}\nSupported formats: ${Object.keys(
|
|
145
|
+
AUDIO_FORMATS
|
|
146
|
+
).join(", ")}`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return hasFfmpeg(value);
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
.option(
|
|
154
|
+
"--adjust-bitrate <string>",
|
|
155
|
+
"adjust bitrate of episode files using ffmpeg",
|
|
141
156
|
hasFfmpeg
|
|
142
157
|
)
|
|
158
|
+
.option("--mono", "force episode files into mono using ffmpeg", hasFfmpeg)
|
|
143
159
|
.option("--override", "override local files on collision")
|
|
144
160
|
.option(
|
|
145
161
|
"--always-postprocess",
|
package/bin/ffmpeg.js
CHANGED
|
@@ -2,24 +2,28 @@ import dayjs from "dayjs";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import { execWithPromise } from "./exec.js";
|
|
4
4
|
import { LOG_LEVELS, logMessage } from "./logger.js";
|
|
5
|
-
import { escapeArgForShell, isWin } from "./util.js";
|
|
5
|
+
import { AUDIO_FORMATS, escapeArgForShell, isWin } from "./util.js";
|
|
6
6
|
|
|
7
7
|
export const runFfmpeg = async ({
|
|
8
|
+
audioFormat,
|
|
9
|
+
bitrate,
|
|
10
|
+
embedMetadata,
|
|
11
|
+
episodeImageOutputPath,
|
|
12
|
+
ext,
|
|
8
13
|
feed,
|
|
9
14
|
item,
|
|
10
15
|
itemIndex,
|
|
11
|
-
outputPath,
|
|
12
|
-
episodeImageOutputPath,
|
|
13
|
-
bitrate,
|
|
14
16
|
mono,
|
|
15
|
-
|
|
16
|
-
ext,
|
|
17
|
+
outputPath,
|
|
17
18
|
}) => {
|
|
18
19
|
if (!fs.existsSync(outputPath)) {
|
|
19
20
|
return;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
const shouldEmbedImage =
|
|
23
|
+
const shouldEmbedImage = embedMetadata && episodeImageOutputPath;
|
|
24
|
+
const targetFormat = audioFormat ? AUDIO_FORMATS[audioFormat] : null;
|
|
25
|
+
const outputExt = targetFormat ? targetFormat.ext : ext;
|
|
26
|
+
|
|
23
27
|
let command = `ffmpeg -loglevel quiet -i ${escapeArgForShell(outputPath)}`;
|
|
24
28
|
|
|
25
29
|
if (shouldEmbedImage) {
|
|
@@ -34,7 +38,22 @@ export const runFfmpeg = async ({
|
|
|
34
38
|
command += " -ac 1";
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
if (
|
|
41
|
+
if (targetFormat) {
|
|
42
|
+
command += ` -c:a ${targetFormat.codec}`;
|
|
43
|
+
} else if (embedMetadata && !bitrate && !mono) {
|
|
44
|
+
command += ` -c:a copy`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (shouldEmbedImage) {
|
|
48
|
+
const supportsAttachedPic = targetFormat
|
|
49
|
+
? targetFormat.attachedPic
|
|
50
|
+
: Object.values(AUDIO_FORMATS).find((f) => f.ext === ext)?.attachedPic;
|
|
51
|
+
command += supportsAttachedPic
|
|
52
|
+
? ` -c:v copy -disposition:v:0 attached_pic`
|
|
53
|
+
: ` -c:v copy`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (embedMetadata) {
|
|
38
57
|
const album = feed.title || "";
|
|
39
58
|
const artist = item.itunes?.author || item.author || "";
|
|
40
59
|
const title = item.title || "";
|
|
@@ -78,32 +97,42 @@ export const runFfmpeg = async ({
|
|
|
78
97
|
.join(" ");
|
|
79
98
|
|
|
80
99
|
command += ` -map_metadata 0 ${metadataString}`;
|
|
81
|
-
|
|
82
|
-
if (!bitrate && !mono) {
|
|
83
|
-
command += ` -codec copy`;
|
|
84
|
-
}
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
if (shouldEmbedImage) {
|
|
88
|
-
command += ` -map 0 -map 1`;
|
|
103
|
+
command += ` -map 0:a -map 1`;
|
|
104
|
+
} else if (targetFormat) {
|
|
105
|
+
command += ` -map 0:a`;
|
|
89
106
|
} else {
|
|
90
107
|
command += ` -map 0`;
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
const
|
|
94
|
-
command += ` ${escapeArgForShell(
|
|
110
|
+
const tmpPath = `${outputPath}.tmp${outputExt}`;
|
|
111
|
+
command += ` ${escapeArgForShell(tmpPath)}`;
|
|
95
112
|
logMessage("Running command: " + command, LOG_LEVELS.debug);
|
|
96
113
|
|
|
97
114
|
try {
|
|
98
115
|
await execWithPromise(command, { stdio: "ignore" });
|
|
99
116
|
} catch (error) {
|
|
100
|
-
if (fs.existsSync(
|
|
101
|
-
fs.unlinkSync(
|
|
117
|
+
if (fs.existsSync(tmpPath)) {
|
|
118
|
+
fs.unlinkSync(tmpPath);
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
throw error;
|
|
105
122
|
}
|
|
106
123
|
|
|
107
124
|
fs.unlinkSync(outputPath);
|
|
108
|
-
|
|
125
|
+
|
|
126
|
+
const finalOutputPath = (() => {
|
|
127
|
+
if (!targetFormat) {
|
|
128
|
+
return outputPath;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hasExt = /\.[^.]+$/.test(outputPath);
|
|
132
|
+
return hasExt
|
|
133
|
+
? outputPath.replace(/\.[^.]+$/, outputExt)
|
|
134
|
+
: outputPath + outputExt;
|
|
135
|
+
})();
|
|
136
|
+
|
|
137
|
+
fs.renameSync(tmpPath, finalOutputPath);
|
|
109
138
|
};
|
package/bin/items.js
CHANGED
|
@@ -1,247 +1,247 @@
|
|
|
1
|
-
import dayjs from "dayjs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { getArchive, getArchiveFilename, getArchiveKey } from "./archive.js";
|
|
4
|
-
import { logErrorAndExit } from "./logger.js";
|
|
5
|
-
import { getItemFilename } from "./naming.js";
|
|
6
|
-
import {
|
|
7
|
-
getEpisodeAudioUrlAndExt,
|
|
8
|
-
getImageUrl,
|
|
9
|
-
getLoopControls,
|
|
10
|
-
getTranscriptUrl,
|
|
11
|
-
getUrlExt,
|
|
12
|
-
} from "./util.js";
|
|
13
|
-
|
|
14
|
-
export const ITEM_LIST_FORMATS = ["table", "json"];
|
|
15
|
-
|
|
16
|
-
export const getItemsToDownload = ({
|
|
17
|
-
archive,
|
|
18
|
-
archivePrefix,
|
|
19
|
-
|
|
20
|
-
basePath,
|
|
21
|
-
feed,
|
|
22
|
-
limit,
|
|
23
|
-
offset,
|
|
24
|
-
reverse,
|
|
25
|
-
before,
|
|
26
|
-
after,
|
|
27
|
-
episodeDigits,
|
|
28
|
-
episodeNumOffset,
|
|
29
|
-
episodeRegex,
|
|
30
|
-
episodeRegexExclude,
|
|
31
|
-
episodeSourceOrder,
|
|
32
|
-
episodeTemplate,
|
|
33
|
-
episodeCustomTemplateOptions,
|
|
34
|
-
includeEpisodeImages,
|
|
35
|
-
includeEpisodeTranscripts,
|
|
36
|
-
episodeTranscriptTypes,
|
|
37
|
-
season,
|
|
38
|
-
}) => {
|
|
39
|
-
const { startIndex, shouldGo, next } = getLoopControls({
|
|
40
|
-
offset,
|
|
41
|
-
reverse,
|
|
42
|
-
length: feed.items.length,
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
let i = startIndex;
|
|
46
|
-
const items = [];
|
|
47
|
-
|
|
48
|
-
const savedArchive = archive ? getArchive(archive) : [];
|
|
49
|
-
|
|
50
|
-
while (shouldGo(i)) {
|
|
51
|
-
const { title, pubDate, itunes } = feed.items[i];
|
|
52
|
-
const actualSeasonNum = itunes?.season ? parseInt(itunes.season) : null;
|
|
53
|
-
const pubDateDay = dayjs(new Date(pubDate));
|
|
54
|
-
let isValid = true;
|
|
55
|
-
|
|
56
|
-
if (episodeRegex) {
|
|
57
|
-
const generatedEpisodeRegex = new RegExp(episodeRegex);
|
|
58
|
-
if (title && !generatedEpisodeRegex.test(title)) {
|
|
59
|
-
isValid = false;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (episodeRegexExclude) {
|
|
64
|
-
const generatedEpisodeRegexExclude = new RegExp(episodeRegexExclude);
|
|
65
|
-
if (title && generatedEpisodeRegexExclude.test(title)) {
|
|
66
|
-
isValid = false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (before) {
|
|
71
|
-
const beforeDateDay = dayjs(new Date(before));
|
|
72
|
-
if (
|
|
73
|
-
!pubDateDay.isSame(beforeDateDay, "day") &&
|
|
74
|
-
!pubDateDay.isBefore(beforeDateDay, "day")
|
|
75
|
-
) {
|
|
76
|
-
isValid = false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (after) {
|
|
81
|
-
const afterDateDay = dayjs(new Date(after));
|
|
82
|
-
if (
|
|
83
|
-
!pubDateDay.isSame(afterDateDay, "day") &&
|
|
84
|
-
!pubDateDay.isAfter(afterDateDay, "day")
|
|
85
|
-
) {
|
|
86
|
-
isValid = false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (season && season != actualSeasonNum) {
|
|
91
|
-
isValid = false;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const { url: episodeAudioUrl, ext: audioFileExt } =
|
|
95
|
-
getEpisodeAudioUrlAndExt(feed.items[i], episodeSourceOrder);
|
|
96
|
-
|
|
97
|
-
const key = getArchiveKey({
|
|
98
|
-
prefix: archivePrefix,
|
|
99
|
-
name: getArchiveFilename({
|
|
100
|
-
pubDate,
|
|
101
|
-
name: title,
|
|
102
|
-
ext: audioFileExt,
|
|
103
|
-
}),
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
if (key && savedArchive.includes(key)) {
|
|
107
|
-
isValid = false;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (isValid) {
|
|
111
|
-
const item = feed.items[i];
|
|
112
|
-
item._originalIndex = i;
|
|
113
|
-
item.seasonNum = actualSeasonNum;
|
|
114
|
-
|
|
115
|
-
if (includeEpisodeImages ||
|
|
116
|
-
const episodeImageUrl = getImageUrl(item);
|
|
117
|
-
|
|
118
|
-
if (episodeImageUrl) {
|
|
119
|
-
const episodeImageFileExt = getUrlExt(episodeImageUrl);
|
|
120
|
-
const episodeImageArchiveKey = getArchiveKey({
|
|
121
|
-
prefix: archivePrefix,
|
|
122
|
-
name: getArchiveFilename({
|
|
123
|
-
pubDate,
|
|
124
|
-
name: title,
|
|
125
|
-
ext: episodeImageFileExt,
|
|
126
|
-
}),
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const episodeImageName = getItemFilename({
|
|
130
|
-
item,
|
|
131
|
-
feed,
|
|
132
|
-
url: episodeAudioUrl,
|
|
133
|
-
ext: episodeImageFileExt,
|
|
134
|
-
template: episodeTemplate,
|
|
135
|
-
customTemplateOptions: episodeCustomTemplateOptions,
|
|
136
|
-
width: episodeDigits,
|
|
137
|
-
offset: episodeNumOffset,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const outputImagePath = path.resolve(basePath, episodeImageName);
|
|
141
|
-
item._episodeImage = {
|
|
142
|
-
url: episodeImageUrl,
|
|
143
|
-
outputPath: outputImagePath,
|
|
144
|
-
key: episodeImageArchiveKey,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (includeEpisodeTranscripts) {
|
|
150
|
-
const episodeTranscriptUrl = getTranscriptUrl(
|
|
151
|
-
item,
|
|
152
|
-
episodeTranscriptTypes
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
if (episodeTranscriptUrl) {
|
|
156
|
-
const episodeTranscriptFileExt = getUrlExt(episodeTranscriptUrl);
|
|
157
|
-
const episodeTranscriptArchiveKey = getArchiveKey({
|
|
158
|
-
prefix: archivePrefix,
|
|
159
|
-
name: getArchiveFilename({
|
|
160
|
-
pubDate,
|
|
161
|
-
name: title,
|
|
162
|
-
ext: episodeTranscriptFileExt,
|
|
163
|
-
}),
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const episodeTranscriptName = getItemFilename({
|
|
167
|
-
item,
|
|
168
|
-
feed,
|
|
169
|
-
url: episodeAudioUrl,
|
|
170
|
-
ext: episodeTranscriptFileExt,
|
|
171
|
-
template: episodeTemplate,
|
|
172
|
-
width: episodeDigits,
|
|
173
|
-
offset: episodeNumOffset,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
const outputTranscriptPath = path.resolve(
|
|
177
|
-
basePath,
|
|
178
|
-
episodeTranscriptName
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
item._episodeTranscript = {
|
|
182
|
-
url: episodeTranscriptUrl,
|
|
183
|
-
outputPath: outputTranscriptPath,
|
|
184
|
-
key: episodeTranscriptArchiveKey,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
items.push(item);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
i = next(i);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return limit ? items.slice(0, limit) : items;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
export const logItemsList = ({
|
|
199
|
-
type,
|
|
200
|
-
feed,
|
|
201
|
-
limit,
|
|
202
|
-
offset,
|
|
203
|
-
reverse,
|
|
204
|
-
before,
|
|
205
|
-
after,
|
|
206
|
-
episodeRegex,
|
|
207
|
-
episodeRegexExclude,
|
|
208
|
-
season,
|
|
209
|
-
}) => {
|
|
210
|
-
const items = getItemsToDownload({
|
|
211
|
-
feed,
|
|
212
|
-
limit,
|
|
213
|
-
offset,
|
|
214
|
-
reverse,
|
|
215
|
-
before,
|
|
216
|
-
after,
|
|
217
|
-
episodeRegex,
|
|
218
|
-
episodeRegexExclude,
|
|
219
|
-
season,
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
if (!items.length) {
|
|
223
|
-
logErrorAndExit("No episodes found with provided criteria to list");
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const isJson = type === "json";
|
|
227
|
-
|
|
228
|
-
const output = items.map((item) => {
|
|
229
|
-
const data = {
|
|
230
|
-
seasonNum: item.seasonNum,
|
|
231
|
-
episodeNum: feed.items.length - item._originalIndex,
|
|
232
|
-
title: item.title,
|
|
233
|
-
pubDate: item.pubDate,
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
return data;
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
if (isJson) {
|
|
240
|
-
// eslint-disable-next-line no-console
|
|
241
|
-
console.log(JSON.stringify(output));
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// eslint-disable-next-line no-console
|
|
246
|
-
console.table(output);
|
|
247
|
-
};
|
|
1
|
+
import dayjs from "dayjs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { getArchive, getArchiveFilename, getArchiveKey } from "./archive.js";
|
|
4
|
+
import { logErrorAndExit } from "./logger.js";
|
|
5
|
+
import { getItemFilename } from "./naming.js";
|
|
6
|
+
import {
|
|
7
|
+
getEpisodeAudioUrlAndExt,
|
|
8
|
+
getImageUrl,
|
|
9
|
+
getLoopControls,
|
|
10
|
+
getTranscriptUrl,
|
|
11
|
+
getUrlExt,
|
|
12
|
+
} from "./util.js";
|
|
13
|
+
|
|
14
|
+
export const ITEM_LIST_FORMATS = ["table", "json"];
|
|
15
|
+
|
|
16
|
+
export const getItemsToDownload = ({
|
|
17
|
+
archive,
|
|
18
|
+
archivePrefix,
|
|
19
|
+
embedMetadataFlag,
|
|
20
|
+
basePath,
|
|
21
|
+
feed,
|
|
22
|
+
limit,
|
|
23
|
+
offset,
|
|
24
|
+
reverse,
|
|
25
|
+
before,
|
|
26
|
+
after,
|
|
27
|
+
episodeDigits,
|
|
28
|
+
episodeNumOffset,
|
|
29
|
+
episodeRegex,
|
|
30
|
+
episodeRegexExclude,
|
|
31
|
+
episodeSourceOrder,
|
|
32
|
+
episodeTemplate,
|
|
33
|
+
episodeCustomTemplateOptions,
|
|
34
|
+
includeEpisodeImages,
|
|
35
|
+
includeEpisodeTranscripts,
|
|
36
|
+
episodeTranscriptTypes,
|
|
37
|
+
season,
|
|
38
|
+
}) => {
|
|
39
|
+
const { startIndex, shouldGo, next } = getLoopControls({
|
|
40
|
+
offset,
|
|
41
|
+
reverse,
|
|
42
|
+
length: feed.items.length,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let i = startIndex;
|
|
46
|
+
const items = [];
|
|
47
|
+
|
|
48
|
+
const savedArchive = archive ? getArchive(archive) : [];
|
|
49
|
+
|
|
50
|
+
while (shouldGo(i)) {
|
|
51
|
+
const { title, pubDate, itunes } = feed.items[i];
|
|
52
|
+
const actualSeasonNum = itunes?.season ? parseInt(itunes.season) : null;
|
|
53
|
+
const pubDateDay = dayjs(new Date(pubDate));
|
|
54
|
+
let isValid = true;
|
|
55
|
+
|
|
56
|
+
if (episodeRegex) {
|
|
57
|
+
const generatedEpisodeRegex = new RegExp(episodeRegex);
|
|
58
|
+
if (title && !generatedEpisodeRegex.test(title)) {
|
|
59
|
+
isValid = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (episodeRegexExclude) {
|
|
64
|
+
const generatedEpisodeRegexExclude = new RegExp(episodeRegexExclude);
|
|
65
|
+
if (title && generatedEpisodeRegexExclude.test(title)) {
|
|
66
|
+
isValid = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (before) {
|
|
71
|
+
const beforeDateDay = dayjs(new Date(before));
|
|
72
|
+
if (
|
|
73
|
+
!pubDateDay.isSame(beforeDateDay, "day") &&
|
|
74
|
+
!pubDateDay.isBefore(beforeDateDay, "day")
|
|
75
|
+
) {
|
|
76
|
+
isValid = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (after) {
|
|
81
|
+
const afterDateDay = dayjs(new Date(after));
|
|
82
|
+
if (
|
|
83
|
+
!pubDateDay.isSame(afterDateDay, "day") &&
|
|
84
|
+
!pubDateDay.isAfter(afterDateDay, "day")
|
|
85
|
+
) {
|
|
86
|
+
isValid = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (season && season != actualSeasonNum) {
|
|
91
|
+
isValid = false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { url: episodeAudioUrl, ext: audioFileExt } =
|
|
95
|
+
getEpisodeAudioUrlAndExt(feed.items[i], episodeSourceOrder);
|
|
96
|
+
|
|
97
|
+
const key = getArchiveKey({
|
|
98
|
+
prefix: archivePrefix,
|
|
99
|
+
name: getArchiveFilename({
|
|
100
|
+
pubDate,
|
|
101
|
+
name: title,
|
|
102
|
+
ext: audioFileExt,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (key && savedArchive.includes(key)) {
|
|
107
|
+
isValid = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isValid) {
|
|
111
|
+
const item = feed.items[i];
|
|
112
|
+
item._originalIndex = i;
|
|
113
|
+
item.seasonNum = actualSeasonNum;
|
|
114
|
+
|
|
115
|
+
if (includeEpisodeImages || embedMetadataFlag) {
|
|
116
|
+
const episodeImageUrl = getImageUrl(item);
|
|
117
|
+
|
|
118
|
+
if (episodeImageUrl) {
|
|
119
|
+
const episodeImageFileExt = getUrlExt(episodeImageUrl);
|
|
120
|
+
const episodeImageArchiveKey = getArchiveKey({
|
|
121
|
+
prefix: archivePrefix,
|
|
122
|
+
name: getArchiveFilename({
|
|
123
|
+
pubDate,
|
|
124
|
+
name: title,
|
|
125
|
+
ext: episodeImageFileExt,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const episodeImageName = getItemFilename({
|
|
130
|
+
item,
|
|
131
|
+
feed,
|
|
132
|
+
url: episodeAudioUrl,
|
|
133
|
+
ext: episodeImageFileExt,
|
|
134
|
+
template: episodeTemplate,
|
|
135
|
+
customTemplateOptions: episodeCustomTemplateOptions,
|
|
136
|
+
width: episodeDigits,
|
|
137
|
+
offset: episodeNumOffset,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const outputImagePath = path.resolve(basePath, episodeImageName);
|
|
141
|
+
item._episodeImage = {
|
|
142
|
+
url: episodeImageUrl,
|
|
143
|
+
outputPath: outputImagePath,
|
|
144
|
+
key: episodeImageArchiveKey,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (includeEpisodeTranscripts) {
|
|
150
|
+
const episodeTranscriptUrl = getTranscriptUrl(
|
|
151
|
+
item,
|
|
152
|
+
episodeTranscriptTypes
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (episodeTranscriptUrl) {
|
|
156
|
+
const episodeTranscriptFileExt = getUrlExt(episodeTranscriptUrl);
|
|
157
|
+
const episodeTranscriptArchiveKey = getArchiveKey({
|
|
158
|
+
prefix: archivePrefix,
|
|
159
|
+
name: getArchiveFilename({
|
|
160
|
+
pubDate,
|
|
161
|
+
name: title,
|
|
162
|
+
ext: episodeTranscriptFileExt,
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const episodeTranscriptName = getItemFilename({
|
|
167
|
+
item,
|
|
168
|
+
feed,
|
|
169
|
+
url: episodeAudioUrl,
|
|
170
|
+
ext: episodeTranscriptFileExt,
|
|
171
|
+
template: episodeTemplate,
|
|
172
|
+
width: episodeDigits,
|
|
173
|
+
offset: episodeNumOffset,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const outputTranscriptPath = path.resolve(
|
|
177
|
+
basePath,
|
|
178
|
+
episodeTranscriptName
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
item._episodeTranscript = {
|
|
182
|
+
url: episodeTranscriptUrl,
|
|
183
|
+
outputPath: outputTranscriptPath,
|
|
184
|
+
key: episodeTranscriptArchiveKey,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
items.push(item);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
i = next(i);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return limit ? items.slice(0, limit) : items;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const logItemsList = ({
|
|
199
|
+
type,
|
|
200
|
+
feed,
|
|
201
|
+
limit,
|
|
202
|
+
offset,
|
|
203
|
+
reverse,
|
|
204
|
+
before,
|
|
205
|
+
after,
|
|
206
|
+
episodeRegex,
|
|
207
|
+
episodeRegexExclude,
|
|
208
|
+
season,
|
|
209
|
+
}) => {
|
|
210
|
+
const items = getItemsToDownload({
|
|
211
|
+
feed,
|
|
212
|
+
limit,
|
|
213
|
+
offset,
|
|
214
|
+
reverse,
|
|
215
|
+
before,
|
|
216
|
+
after,
|
|
217
|
+
episodeRegex,
|
|
218
|
+
episodeRegexExclude,
|
|
219
|
+
season,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (!items.length) {
|
|
223
|
+
logErrorAndExit("No episodes found with provided criteria to list");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const isJson = type === "json";
|
|
227
|
+
|
|
228
|
+
const output = items.map((item) => {
|
|
229
|
+
const data = {
|
|
230
|
+
seasonNum: item.seasonNum,
|
|
231
|
+
episodeNum: feed.items.length - item._originalIndex,
|
|
232
|
+
title: item.title,
|
|
233
|
+
pubDate: item.pubDate,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return data;
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (isJson) {
|
|
240
|
+
// eslint-disable-next-line no-console
|
|
241
|
+
console.log(JSON.stringify(output));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// eslint-disable-next-line no-console
|
|
246
|
+
console.table(output);
|
|
247
|
+
};
|
package/bin/util.js
CHANGED
|
@@ -3,6 +3,16 @@ import path from "path";
|
|
|
3
3
|
import rssParser from "../lib/rss-parser/parser.js";
|
|
4
4
|
import { logErrorAndExit, logMessage } from "./logger.js";
|
|
5
5
|
|
|
6
|
+
export const AUDIO_FORMATS = {
|
|
7
|
+
m4a: { codec: "aac", ext: ".m4a", attachedPic: true },
|
|
8
|
+
aac: { codec: "aac", ext: ".aac" },
|
|
9
|
+
mp3: { codec: "libmp3lame", ext: ".mp3", attachedPic: true },
|
|
10
|
+
opus: { codec: "libopus", ext: ".opus" },
|
|
11
|
+
ogg: { codec: "libvorbis", ext: ".ogg" },
|
|
12
|
+
flac: { codec: "flac", ext: ".flac" },
|
|
13
|
+
wav: { codec: "pcm_s16le", ext: ".wav" },
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
export const isWin = process.platform === "win32";
|
|
7
17
|
|
|
8
18
|
export const defaultRssParserConfig = {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const feed = [
|
|
2
|
+
["author", "creator"],
|
|
3
|
+
["dc:publisher", "publisher"],
|
|
4
|
+
["dc:creator", "creator"],
|
|
5
|
+
["dc:source", "source"],
|
|
6
|
+
["dc:title", "title"],
|
|
7
|
+
["dc:type", "type"],
|
|
8
|
+
"title",
|
|
9
|
+
"description",
|
|
10
|
+
"author",
|
|
11
|
+
"pubDate",
|
|
12
|
+
"webMaster",
|
|
13
|
+
"managingEditor",
|
|
14
|
+
"generator",
|
|
15
|
+
"link",
|
|
16
|
+
"language",
|
|
17
|
+
"copyright",
|
|
18
|
+
"lastBuildDate",
|
|
19
|
+
"docs",
|
|
20
|
+
"generator",
|
|
21
|
+
"ttl",
|
|
22
|
+
"rating",
|
|
23
|
+
"skipHours",
|
|
24
|
+
"skipDays",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const item = [
|
|
28
|
+
["author", "creator"],
|
|
29
|
+
["dc:creator", "creator"],
|
|
30
|
+
["dc:date", "date"],
|
|
31
|
+
["dc:language", "language"],
|
|
32
|
+
["dc:rights", "rights"],
|
|
33
|
+
["dc:source", "source"],
|
|
34
|
+
["dc:title", "title"],
|
|
35
|
+
"title",
|
|
36
|
+
"link",
|
|
37
|
+
"pubDate",
|
|
38
|
+
"author",
|
|
39
|
+
"summary",
|
|
40
|
+
["content:encoded", "content:encoded", { includeSnippet: true }],
|
|
41
|
+
"enclosure",
|
|
42
|
+
"dc:creator",
|
|
43
|
+
"dc:date",
|
|
44
|
+
"comments",
|
|
45
|
+
["podcast:transcript", "podcastTranscripts", { keepArray: true }],
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const mapItunesField = (f) => ["itunes:" + f, f];
|
|
49
|
+
|
|
50
|
+
export const podcastFeed = ["author", "subtitle", "summary", "explicit"].map(
|
|
51
|
+
mapItunesField
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export const podcastItem = [
|
|
55
|
+
"author",
|
|
56
|
+
"subtitle",
|
|
57
|
+
"summary",
|
|
58
|
+
"explicit",
|
|
59
|
+
"duration",
|
|
60
|
+
"image",
|
|
61
|
+
"episode",
|
|
62
|
+
"image",
|
|
63
|
+
"season",
|
|
64
|
+
"keywords",
|
|
65
|
+
"episodeType",
|
|
66
|
+
].map(mapItunesField);
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import https from "https";
|
|
3
|
+
import xml2js from "xml2js";
|
|
4
|
+
|
|
5
|
+
import * as fields from "./fields.js";
|
|
6
|
+
import * as utils from "./utils.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_HEADERS = {
|
|
9
|
+
"User-Agent": "rss-parser",
|
|
10
|
+
Accept: "application/rss+xml",
|
|
11
|
+
};
|
|
12
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
13
|
+
const DEFAULT_TIMEOUT = 60000;
|
|
14
|
+
|
|
15
|
+
class Parser {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
options.headers = options.headers || {};
|
|
18
|
+
options.xml2js = options.xml2js || {};
|
|
19
|
+
options.customFields = options.customFields || {};
|
|
20
|
+
options.customFields.item = options.customFields.item || [];
|
|
21
|
+
options.customFields.feed = options.customFields.feed || [];
|
|
22
|
+
options.requestOptions = options.requestOptions || {};
|
|
23
|
+
if (!options.maxRedirects) options.maxRedirects = DEFAULT_MAX_REDIRECTS;
|
|
24
|
+
if (!options.timeout) options.timeout = DEFAULT_TIMEOUT;
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.xmlParser = new xml2js.Parser(this.options.xml2js);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
parseString(xml, callback) {
|
|
30
|
+
let prom = new Promise((resolve, reject) => {
|
|
31
|
+
this.xmlParser.parseString(xml, (err, result) => {
|
|
32
|
+
if (err) return reject(err);
|
|
33
|
+
if (!result) {
|
|
34
|
+
return reject(new Error("Unable to parse XML."));
|
|
35
|
+
}
|
|
36
|
+
let feed = null;
|
|
37
|
+
if (result.feed) {
|
|
38
|
+
feed = this.buildAtomFeed(result);
|
|
39
|
+
} else if (
|
|
40
|
+
result.rss &&
|
|
41
|
+
result.rss.$ &&
|
|
42
|
+
result.rss.$.version &&
|
|
43
|
+
result.rss.$.version.match(/^2/)
|
|
44
|
+
) {
|
|
45
|
+
feed = this.buildRSS2(result);
|
|
46
|
+
} else if (result["rdf:RDF"]) {
|
|
47
|
+
feed = this.buildRSS1(result);
|
|
48
|
+
} else if (
|
|
49
|
+
result.rss &&
|
|
50
|
+
result.rss.$ &&
|
|
51
|
+
result.rss.$.version &&
|
|
52
|
+
result.rss.$.version.match(/0\.9/)
|
|
53
|
+
) {
|
|
54
|
+
feed = this.buildRSS0_9(result);
|
|
55
|
+
} else if (result.rss && this.options.defaultRSS) {
|
|
56
|
+
switch (this.options.defaultRSS) {
|
|
57
|
+
case 0.9:
|
|
58
|
+
feed = this.buildRSS0_9(result);
|
|
59
|
+
break;
|
|
60
|
+
case 1:
|
|
61
|
+
feed = this.buildRSS1(result);
|
|
62
|
+
break;
|
|
63
|
+
case 2:
|
|
64
|
+
feed = this.buildRSS2(result);
|
|
65
|
+
break;
|
|
66
|
+
default:
|
|
67
|
+
return reject(new Error("default RSS version not recognized."));
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
return reject(new Error("Feed not recognized as RSS 1 or 2."));
|
|
71
|
+
}
|
|
72
|
+
resolve(feed);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
prom = utils.maybePromisify(callback, prom);
|
|
76
|
+
return prom;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
parseURL(feedUrl, callback, redirectCount = 0) {
|
|
80
|
+
let xml = "";
|
|
81
|
+
let get = feedUrl.indexOf("https") === 0 ? https.get : http.get;
|
|
82
|
+
let parsedUrl = new URL(feedUrl);
|
|
83
|
+
let urlParts = {
|
|
84
|
+
hostname: parsedUrl.hostname,
|
|
85
|
+
port: parsedUrl.port || undefined,
|
|
86
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
87
|
+
protocol: parsedUrl.protocol,
|
|
88
|
+
};
|
|
89
|
+
let headers = Object.assign({}, DEFAULT_HEADERS, this.options.headers);
|
|
90
|
+
let timeout = null;
|
|
91
|
+
let prom = new Promise((resolve, reject) => {
|
|
92
|
+
const requestOpts = Object.assign(
|
|
93
|
+
{ headers },
|
|
94
|
+
urlParts,
|
|
95
|
+
this.options.requestOptions
|
|
96
|
+
);
|
|
97
|
+
let req = get(requestOpts, (res) => {
|
|
98
|
+
if (
|
|
99
|
+
this.options.maxRedirects &&
|
|
100
|
+
res.statusCode >= 300 &&
|
|
101
|
+
res.statusCode < 400 &&
|
|
102
|
+
res.headers["location"]
|
|
103
|
+
) {
|
|
104
|
+
if (redirectCount === this.options.maxRedirects) {
|
|
105
|
+
return reject(new Error("Too many redirects"));
|
|
106
|
+
} else {
|
|
107
|
+
const newLocation = new URL(res.headers["location"], feedUrl).href;
|
|
108
|
+
return this.parseURL(newLocation, null, redirectCount + 1).then(
|
|
109
|
+
resolve,
|
|
110
|
+
reject
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
} else if (res.statusCode >= 300) {
|
|
114
|
+
return reject(new Error("Status code " + res.statusCode));
|
|
115
|
+
}
|
|
116
|
+
let encoding = utils.getEncodingFromContentType(
|
|
117
|
+
res.headers["content-type"]
|
|
118
|
+
);
|
|
119
|
+
res.setEncoding(encoding);
|
|
120
|
+
res.on("data", (chunk) => {
|
|
121
|
+
xml += chunk;
|
|
122
|
+
});
|
|
123
|
+
res.on("end", () => {
|
|
124
|
+
return this.parseString(xml).then(resolve, reject);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
req.on("error", reject);
|
|
128
|
+
timeout = setTimeout(() => {
|
|
129
|
+
return reject(
|
|
130
|
+
new Error("Request timed out after " + this.options.timeout + "ms")
|
|
131
|
+
);
|
|
132
|
+
}, this.options.timeout);
|
|
133
|
+
}).then(
|
|
134
|
+
(data) => {
|
|
135
|
+
clearTimeout(timeout);
|
|
136
|
+
return Promise.resolve(data);
|
|
137
|
+
},
|
|
138
|
+
(e) => {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
return Promise.reject(e);
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
prom = utils.maybePromisify(callback, prom);
|
|
144
|
+
return prom;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
buildAtomFeed(xmlObj) {
|
|
148
|
+
let feed = { items: [] };
|
|
149
|
+
utils.copyFromXML(xmlObj.feed, feed, this.options.customFields.feed);
|
|
150
|
+
if (xmlObj.feed.link) {
|
|
151
|
+
feed.link = utils.getLink(xmlObj.feed.link, "alternate", 0);
|
|
152
|
+
feed.feedUrl = utils.getLink(xmlObj.feed.link, "self", 1);
|
|
153
|
+
}
|
|
154
|
+
if (xmlObj.feed.title) {
|
|
155
|
+
let title = xmlObj.feed.title[0] || "";
|
|
156
|
+
if (title._) title = title._;
|
|
157
|
+
if (title) feed.title = title;
|
|
158
|
+
}
|
|
159
|
+
if (xmlObj.feed.updated) {
|
|
160
|
+
feed.lastBuildDate = xmlObj.feed.updated[0];
|
|
161
|
+
}
|
|
162
|
+
feed.items = (xmlObj.feed.entry || []).map((entry) =>
|
|
163
|
+
this.parseItemAtom(entry)
|
|
164
|
+
);
|
|
165
|
+
return feed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
parseItemAtom(entry) {
|
|
169
|
+
let item = {};
|
|
170
|
+
utils.copyFromXML(entry, item, this.options.customFields.item);
|
|
171
|
+
if (entry.title) {
|
|
172
|
+
let title = entry.title[0] || "";
|
|
173
|
+
if (title._) title = title._;
|
|
174
|
+
if (title) item.title = title;
|
|
175
|
+
}
|
|
176
|
+
if (entry.link && entry.link.length) {
|
|
177
|
+
item.link = utils.getLink(entry.link, "alternate", 0);
|
|
178
|
+
}
|
|
179
|
+
if (entry.published && entry.published.length && entry.published[0].length)
|
|
180
|
+
item.pubDate = new Date(entry.published[0]).toISOString();
|
|
181
|
+
if (
|
|
182
|
+
!item.pubDate &&
|
|
183
|
+
entry.updated &&
|
|
184
|
+
entry.updated.length &&
|
|
185
|
+
entry.updated[0].length
|
|
186
|
+
)
|
|
187
|
+
item.pubDate = new Date(entry.updated[0]).toISOString();
|
|
188
|
+
if (
|
|
189
|
+
entry.author &&
|
|
190
|
+
entry.author.length &&
|
|
191
|
+
entry.author[0].name &&
|
|
192
|
+
entry.author[0].name.length
|
|
193
|
+
)
|
|
194
|
+
item.author = entry.author[0].name[0];
|
|
195
|
+
if (entry.content && entry.content.length) {
|
|
196
|
+
item.content = utils.getContent(entry.content[0]);
|
|
197
|
+
item.contentSnippet = utils.getSnippet(item.content);
|
|
198
|
+
}
|
|
199
|
+
if (entry.summary && entry.summary.length) {
|
|
200
|
+
item.summary = utils.getContent(entry.summary[0]);
|
|
201
|
+
}
|
|
202
|
+
if (entry.id) {
|
|
203
|
+
item.id = entry.id[0];
|
|
204
|
+
}
|
|
205
|
+
this.setISODate(item);
|
|
206
|
+
return item;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
buildRSS0_9(xmlObj) {
|
|
210
|
+
var channel = xmlObj.rss.channel[0];
|
|
211
|
+
var items = channel.item;
|
|
212
|
+
return this.buildRSS(channel, items);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
buildRSS1(xmlObj) {
|
|
216
|
+
xmlObj = xmlObj["rdf:RDF"];
|
|
217
|
+
let channel = xmlObj.channel[0];
|
|
218
|
+
let items = xmlObj.item;
|
|
219
|
+
return this.buildRSS(channel, items);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
buildRSS2(xmlObj) {
|
|
223
|
+
let channel = xmlObj.rss.channel[0];
|
|
224
|
+
let items = channel.item;
|
|
225
|
+
let feed = this.buildRSS(channel, items);
|
|
226
|
+
if (xmlObj.rss.$ && xmlObj.rss.$["xmlns:itunes"]) {
|
|
227
|
+
this.decorateItunes(feed, channel);
|
|
228
|
+
}
|
|
229
|
+
return feed;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
buildRSS(channel, items) {
|
|
233
|
+
items = items || [];
|
|
234
|
+
let feed = { items: [] };
|
|
235
|
+
let feedFields = fields.feed.concat(this.options.customFields.feed);
|
|
236
|
+
let itemFields = fields.item.concat(this.options.customFields.item);
|
|
237
|
+
|
|
238
|
+
// Fix: Look for atom:link with rel="self" instead of just taking the first one
|
|
239
|
+
if (channel["atom:link"]) {
|
|
240
|
+
feed.feedUrl = utils.getLink(channel["atom:link"], "self", 0);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (channel.image && channel.image[0] && channel.image[0].url) {
|
|
244
|
+
feed.image = {};
|
|
245
|
+
let image = channel.image[0];
|
|
246
|
+
if (image.link) feed.image.link = image.link[0];
|
|
247
|
+
if (image.url) feed.image.url = image.url[0];
|
|
248
|
+
if (image.title) feed.image.title = image.title[0];
|
|
249
|
+
if (image.width) feed.image.width = image.width[0];
|
|
250
|
+
if (image.height) feed.image.height = image.height[0];
|
|
251
|
+
}
|
|
252
|
+
const paginationLinks = this.generatePaginationLinks(channel);
|
|
253
|
+
if (Object.keys(paginationLinks).length) {
|
|
254
|
+
feed.paginationLinks = paginationLinks;
|
|
255
|
+
}
|
|
256
|
+
utils.copyFromXML(channel, feed, feedFields);
|
|
257
|
+
feed.items = items.map((xmlItem) => this.parseItemRss(xmlItem, itemFields));
|
|
258
|
+
return feed;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
parseItemRss(xmlItem, itemFields) {
|
|
262
|
+
let item = {};
|
|
263
|
+
utils.copyFromXML(xmlItem, item, itemFields);
|
|
264
|
+
if (xmlItem.enclosure) {
|
|
265
|
+
item.enclosure = xmlItem.enclosure[0].$;
|
|
266
|
+
}
|
|
267
|
+
if (xmlItem.description) {
|
|
268
|
+
item.content = utils.getContent(xmlItem.description[0]);
|
|
269
|
+
item.contentSnippet = utils.getSnippet(item.content);
|
|
270
|
+
}
|
|
271
|
+
if (xmlItem.guid) {
|
|
272
|
+
item.guid = xmlItem.guid[0];
|
|
273
|
+
if (item.guid._) item.guid = item.guid._;
|
|
274
|
+
}
|
|
275
|
+
if (xmlItem.$ && xmlItem.$["rdf:about"]) {
|
|
276
|
+
item["rdf:about"] = xmlItem.$["rdf:about"];
|
|
277
|
+
}
|
|
278
|
+
if (xmlItem.category) item.categories = xmlItem.category;
|
|
279
|
+
this.setISODate(item);
|
|
280
|
+
return item;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
decorateItunes(feed, channel) {
|
|
284
|
+
let items = channel.item || [];
|
|
285
|
+
feed.itunes = {};
|
|
286
|
+
|
|
287
|
+
if (channel["itunes:owner"]) {
|
|
288
|
+
let owner = {};
|
|
289
|
+
|
|
290
|
+
if (channel["itunes:owner"][0]["itunes:name"]) {
|
|
291
|
+
owner.name = channel["itunes:owner"][0]["itunes:name"][0];
|
|
292
|
+
}
|
|
293
|
+
if (channel["itunes:owner"][0]["itunes:email"]) {
|
|
294
|
+
owner.email = channel["itunes:owner"][0]["itunes:email"][0];
|
|
295
|
+
}
|
|
296
|
+
feed.itunes.owner = owner;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (channel["itunes:image"]) {
|
|
300
|
+
let image;
|
|
301
|
+
let hasImageHref =
|
|
302
|
+
channel["itunes:image"][0] &&
|
|
303
|
+
channel["itunes:image"][0].$ &&
|
|
304
|
+
channel["itunes:image"][0].$.href;
|
|
305
|
+
image = hasImageHref ? channel["itunes:image"][0].$.href : null;
|
|
306
|
+
if (image) {
|
|
307
|
+
feed.itunes.image = image;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (channel["itunes:category"]) {
|
|
312
|
+
const categoriesWithSubs = channel["itunes:category"].map((category) => {
|
|
313
|
+
return {
|
|
314
|
+
name: category && category.$ && category.$.text,
|
|
315
|
+
subs: category["itunes:category"]
|
|
316
|
+
? category["itunes:category"].map((subcategory) => ({
|
|
317
|
+
name: subcategory && subcategory.$ && subcategory.$.text,
|
|
318
|
+
}))
|
|
319
|
+
: null,
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
feed.itunes.categories = categoriesWithSubs.map(
|
|
324
|
+
(category) => category.name
|
|
325
|
+
);
|
|
326
|
+
feed.itunes.categoriesWithSubs = categoriesWithSubs;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (channel["itunes:keywords"]) {
|
|
330
|
+
if (channel["itunes:keywords"].length > 1) {
|
|
331
|
+
feed.itunes.keywords = channel["itunes:keywords"].map(
|
|
332
|
+
(keyword) => keyword && keyword.$ && keyword.$.text
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
let keywords = channel["itunes:keywords"][0];
|
|
336
|
+
if (keywords && typeof keywords._ === "string") {
|
|
337
|
+
keywords = keywords._;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (keywords && keywords.$ && keywords.$.text) {
|
|
341
|
+
feed.itunes.keywords = keywords.$.text.split(",");
|
|
342
|
+
} else if (typeof keywords === "string") {
|
|
343
|
+
feed.itunes.keywords = keywords.split(",");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
utils.copyFromXML(channel, feed.itunes, fields.podcastFeed);
|
|
349
|
+
items.forEach((item, index) => {
|
|
350
|
+
let entry = feed.items[index];
|
|
351
|
+
entry.itunes = {};
|
|
352
|
+
utils.copyFromXML(item, entry.itunes, fields.podcastItem);
|
|
353
|
+
let image = item["itunes:image"];
|
|
354
|
+
if (image && image[0] && image[0].$ && image[0].$.href) {
|
|
355
|
+
entry.itunes.image = image[0].$.href;
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
setISODate(item) {
|
|
361
|
+
let date = item.pubDate || item.date;
|
|
362
|
+
if (date) {
|
|
363
|
+
try {
|
|
364
|
+
item.isoDate = new Date(date.trim()).toISOString();
|
|
365
|
+
} catch {
|
|
366
|
+
// Ignore bad date format
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
generatePaginationLinks(channel) {
|
|
372
|
+
if (!channel["atom:link"]) {
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
const paginationRelAttributes = ["self", "first", "next", "prev", "last"];
|
|
376
|
+
|
|
377
|
+
return channel["atom:link"].reduce((paginationLinks, link) => {
|
|
378
|
+
if (!link.$ || !paginationRelAttributes.includes(link.$.rel)) {
|
|
379
|
+
return paginationLinks;
|
|
380
|
+
}
|
|
381
|
+
paginationLinks[link.$.rel] = link.$.href;
|
|
382
|
+
return paginationLinks;
|
|
383
|
+
}, {});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export default Parser;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import entities from "entities";
|
|
2
|
+
import xml2js from "xml2js";
|
|
3
|
+
|
|
4
|
+
export const stripHtml = (str) => {
|
|
5
|
+
str = str.replace(
|
|
6
|
+
/([^\n])<\/?(h|br|p|ul|ol|li|blockquote|section|table|tr|div)(?:.|\n)*?>([^\n])/gm,
|
|
7
|
+
"$1\n$3"
|
|
8
|
+
);
|
|
9
|
+
str = str.replace(/<(?:.|\n)*?>/gm, "");
|
|
10
|
+
return str;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getSnippet = (str) => {
|
|
14
|
+
return entities.decodeHTML(stripHtml(str)).trim();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const getLink = (links, rel, fallbackIdx) => {
|
|
18
|
+
if (!links) return;
|
|
19
|
+
for (let i = 0; i < links.length; ++i) {
|
|
20
|
+
if (links[i].$.rel === rel) return links[i].$.href;
|
|
21
|
+
}
|
|
22
|
+
if (links[fallbackIdx]) return links[fallbackIdx].$.href;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const getContent = (content) => {
|
|
26
|
+
if (typeof content._ === "string") {
|
|
27
|
+
return content._;
|
|
28
|
+
} else if (typeof content === "object") {
|
|
29
|
+
let builder = new xml2js.Builder({
|
|
30
|
+
headless: true,
|
|
31
|
+
explicitRoot: true,
|
|
32
|
+
rootName: "div",
|
|
33
|
+
renderOpts: { pretty: false },
|
|
34
|
+
});
|
|
35
|
+
return builder.buildObject(content);
|
|
36
|
+
} else {
|
|
37
|
+
return content;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const copyFromXML = (xml, dest, fields) => {
|
|
42
|
+
fields.forEach((f) => {
|
|
43
|
+
let from = f;
|
|
44
|
+
let to = f;
|
|
45
|
+
let options = {};
|
|
46
|
+
if (Array.isArray(f)) {
|
|
47
|
+
from = f[0];
|
|
48
|
+
to = f[1];
|
|
49
|
+
if (f.length > 2) {
|
|
50
|
+
options = f[2];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const { keepArray, includeSnippet } = options;
|
|
54
|
+
if (xml[from] !== undefined) {
|
|
55
|
+
dest[to] = keepArray ? xml[from] : xml[from][0];
|
|
56
|
+
}
|
|
57
|
+
if (dest[to] && typeof dest[to]._ === "string") {
|
|
58
|
+
dest[to] = dest[to]._;
|
|
59
|
+
}
|
|
60
|
+
if (includeSnippet && dest[to] && typeof dest[to] === "string") {
|
|
61
|
+
dest[to + "Snippet"] = getSnippet(dest[to]);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const maybePromisify = (callback, promise) => {
|
|
67
|
+
if (!callback) return promise;
|
|
68
|
+
return promise.then(
|
|
69
|
+
(data) => setTimeout(() => callback(null, data)),
|
|
70
|
+
(err) => setTimeout(() => callback(err))
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const DEFAULT_ENCODING = "utf8";
|
|
75
|
+
const ENCODING_REGEX = /(encoding|charset)\s*=\s*(\S+)/;
|
|
76
|
+
const SUPPORTED_ENCODINGS = [
|
|
77
|
+
"ascii",
|
|
78
|
+
"utf8",
|
|
79
|
+
"utf16le",
|
|
80
|
+
"ucs2",
|
|
81
|
+
"base64",
|
|
82
|
+
"latin1",
|
|
83
|
+
"binary",
|
|
84
|
+
"hex",
|
|
85
|
+
];
|
|
86
|
+
const ENCODING_ALIASES = {
|
|
87
|
+
"utf-8": "utf8",
|
|
88
|
+
"iso-8859-1": "latin1",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const getEncodingFromContentType = (contentType) => {
|
|
92
|
+
contentType = contentType || "";
|
|
93
|
+
let match = contentType.match(ENCODING_REGEX);
|
|
94
|
+
let encoding = (match || [])[2] || "";
|
|
95
|
+
encoding = encoding.toLowerCase();
|
|
96
|
+
encoding = ENCODING_ALIASES[encoding] || encoding;
|
|
97
|
+
if (!encoding || SUPPORTED_ENCODINGS.indexOf(encoding) === -1) {
|
|
98
|
+
encoding = DEFAULT_ENCODING;
|
|
99
|
+
}
|
|
100
|
+
return encoding;
|
|
101
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "podcast-dl",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.6.1",
|
|
4
4
|
"description": "A CLI for downloading podcasts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./bin/bin.js",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"url": "https://github.com/lightpohl/podcast-dl.git"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
|
-
"bin"
|
|
36
|
+
"bin",
|
|
37
|
+
"lib"
|
|
37
38
|
],
|
|
38
39
|
"author": "Joshua Pohl",
|
|
39
40
|
"license": "MIT",
|