podcast-dl 10.3.3 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -41
- package/bin/async.js +90 -98
- package/bin/bin.js +18 -52
- package/bin/commander.js +8 -11
- package/bin/exec.js +30 -0
- package/bin/ffmpeg.js +101 -0
- package/bin/items.js +203 -0
- package/bin/logger.js +8 -18
- package/bin/meta.js +33 -0
- package/bin/naming.js +6 -24
- package/bin/util.js +25 -459
- package/bin/validate.js +2 -5
- package/package.json +3 -3
package/bin/ffmpeg.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import dayjs from "dayjs";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { execWithPromise } from "./exec.js";
|
|
4
|
+
import { LOG_LEVELS, logMessage } from "./logger.js";
|
|
5
|
+
import { escapeArgForShell } from "./util.js";
|
|
6
|
+
|
|
7
|
+
export const runFfmpeg = async ({
|
|
8
|
+
feed,
|
|
9
|
+
item,
|
|
10
|
+
itemIndex,
|
|
11
|
+
outputPath,
|
|
12
|
+
episodeImageOutputPath,
|
|
13
|
+
bitrate,
|
|
14
|
+
mono,
|
|
15
|
+
addMp3Metadata,
|
|
16
|
+
ext,
|
|
17
|
+
}) => {
|
|
18
|
+
if (!fs.existsSync(outputPath)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const shouldEmbedImage = addMp3Metadata && episodeImageOutputPath;
|
|
23
|
+
let command = `ffmpeg -loglevel quiet -i ${escapeArgForShell(outputPath)}`;
|
|
24
|
+
|
|
25
|
+
if (shouldEmbedImage) {
|
|
26
|
+
command += ` -i ${escapeArgForShell(episodeImageOutputPath)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (bitrate) {
|
|
30
|
+
command += ` -b:a ${bitrate}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (mono) {
|
|
34
|
+
command += " -ac 1";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (addMp3Metadata) {
|
|
38
|
+
const album = feed.title || "";
|
|
39
|
+
const artist = item.itunes?.author || item.author || "";
|
|
40
|
+
const title = item.title || "";
|
|
41
|
+
const subtitle = item.itunes?.subtitle || "";
|
|
42
|
+
const comment = item.contentSnippet || item.content || "";
|
|
43
|
+
const disc = item.itunes?.season || "";
|
|
44
|
+
const track = item.itunes?.episode || `${feed.items.length - itemIndex}`;
|
|
45
|
+
const episodeType = item.itunes?.episodeType || "";
|
|
46
|
+
const date = item.pubDate
|
|
47
|
+
? dayjs(new Date(item.pubDate)).format("YYYY-MM-DD")
|
|
48
|
+
: "";
|
|
49
|
+
|
|
50
|
+
const metaKeysToValues = {
|
|
51
|
+
album,
|
|
52
|
+
artist,
|
|
53
|
+
album_artist: artist,
|
|
54
|
+
title,
|
|
55
|
+
subtitle,
|
|
56
|
+
comment,
|
|
57
|
+
disc,
|
|
58
|
+
track,
|
|
59
|
+
"episode-type": episodeType,
|
|
60
|
+
date,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const metadataString = Object.keys(metaKeysToValues)
|
|
64
|
+
.map((key) => {
|
|
65
|
+
if (!metaKeysToValues[key]) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const argValue = escapeArgForShell(metaKeysToValues[key]);
|
|
70
|
+
|
|
71
|
+
return argValue ? `-metadata ${key}=${argValue}` : null;
|
|
72
|
+
})
|
|
73
|
+
.filter((segment) => !!segment)
|
|
74
|
+
.join(" ");
|
|
75
|
+
|
|
76
|
+
command += ` -map_metadata 0 ${metadataString} -codec copy`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (shouldEmbedImage) {
|
|
80
|
+
command += ` -map 0 -map 1`;
|
|
81
|
+
} else {
|
|
82
|
+
command += ` -map 0`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tmpMp3Path = `${outputPath}.tmp${ext}`;
|
|
86
|
+
command += ` ${escapeArgForShell(tmpMp3Path)}`;
|
|
87
|
+
logMessage("Running command: " + command, LOG_LEVELS.debug);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await execWithPromise(command, { stdio: "ignore" });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (fs.existsSync(tmpMp3Path)) {
|
|
93
|
+
fs.unlinkSync(tmpMp3Path);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fs.unlinkSync(outputPath);
|
|
100
|
+
fs.renameSync(tmpMp3Path, outputPath);
|
|
101
|
+
};
|
package/bin/items.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import dayjs from "dayjs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { getItemFilename } from "./naming.js";
|
|
4
|
+
import {
|
|
5
|
+
getEpisodeAudioUrlAndExt,
|
|
6
|
+
getImageUrl,
|
|
7
|
+
getLoopControls,
|
|
8
|
+
getTranscriptUrl,
|
|
9
|
+
getUrlExt,
|
|
10
|
+
} from "./util.js";
|
|
11
|
+
import { logErrorAndExit } from "./logger.js";
|
|
12
|
+
|
|
13
|
+
export const ITEM_LIST_FORMATS = ["table", "json"];
|
|
14
|
+
|
|
15
|
+
export const getItemsToDownload = ({
|
|
16
|
+
addMp3MetadataFlag,
|
|
17
|
+
basePath,
|
|
18
|
+
feed,
|
|
19
|
+
limit,
|
|
20
|
+
offset,
|
|
21
|
+
reverse,
|
|
22
|
+
before,
|
|
23
|
+
after,
|
|
24
|
+
episodeDigits,
|
|
25
|
+
episodeNumOffset,
|
|
26
|
+
episodeRegex,
|
|
27
|
+
episodeRegexExclude,
|
|
28
|
+
episodeSourceOrder,
|
|
29
|
+
episodeTemplate,
|
|
30
|
+
episodeCustomTemplateOptions,
|
|
31
|
+
includeEpisodeImages,
|
|
32
|
+
includeEpisodeTranscripts,
|
|
33
|
+
episodeTranscriptTypes,
|
|
34
|
+
}) => {
|
|
35
|
+
const { startIndex, shouldGo, next } = getLoopControls({
|
|
36
|
+
offset,
|
|
37
|
+
reverse,
|
|
38
|
+
length: feed.items.length,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let i = startIndex;
|
|
42
|
+
const items = [];
|
|
43
|
+
|
|
44
|
+
while (shouldGo(i)) {
|
|
45
|
+
const { title, pubDate } = feed.items[i];
|
|
46
|
+
const pubDateDay = dayjs(new Date(pubDate));
|
|
47
|
+
let isValid = true;
|
|
48
|
+
|
|
49
|
+
if (episodeRegex) {
|
|
50
|
+
const generatedEpisodeRegex = new RegExp(episodeRegex);
|
|
51
|
+
if (title && !generatedEpisodeRegex.test(title)) {
|
|
52
|
+
isValid = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (episodeRegexExclude) {
|
|
57
|
+
const generatedEpisodeRegexExclude = new RegExp(episodeRegexExclude);
|
|
58
|
+
if (title && generatedEpisodeRegexExclude.test(title)) {
|
|
59
|
+
isValid = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (before) {
|
|
64
|
+
const beforeDateDay = dayjs(new Date(before));
|
|
65
|
+
if (
|
|
66
|
+
!pubDateDay.isSame(beforeDateDay, "day") &&
|
|
67
|
+
!pubDateDay.isBefore(beforeDateDay, "day")
|
|
68
|
+
) {
|
|
69
|
+
isValid = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (after) {
|
|
74
|
+
const afterDateDay = dayjs(new Date(after));
|
|
75
|
+
if (
|
|
76
|
+
!pubDateDay.isSame(afterDateDay, "day") &&
|
|
77
|
+
!pubDateDay.isAfter(afterDateDay, "day")
|
|
78
|
+
) {
|
|
79
|
+
isValid = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { url: episodeAudioUrl } = getEpisodeAudioUrlAndExt(
|
|
84
|
+
feed.items[i],
|
|
85
|
+
episodeSourceOrder
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (isValid) {
|
|
89
|
+
const item = feed.items[i];
|
|
90
|
+
item._originalIndex = i;
|
|
91
|
+
|
|
92
|
+
if (includeEpisodeImages || addMp3MetadataFlag) {
|
|
93
|
+
const episodeImageUrl = getImageUrl(item);
|
|
94
|
+
|
|
95
|
+
if (episodeImageUrl) {
|
|
96
|
+
const episodeImageFileExt = getUrlExt(episodeImageUrl);
|
|
97
|
+
|
|
98
|
+
const episodeImageName = getItemFilename({
|
|
99
|
+
item,
|
|
100
|
+
feed,
|
|
101
|
+
url: episodeAudioUrl,
|
|
102
|
+
ext: episodeImageFileExt,
|
|
103
|
+
template: episodeTemplate,
|
|
104
|
+
customTemplateOptions: episodeCustomTemplateOptions,
|
|
105
|
+
width: episodeDigits,
|
|
106
|
+
offset: episodeNumOffset,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const outputImagePath = path.resolve(basePath, episodeImageName);
|
|
110
|
+
item._episodeImage = {
|
|
111
|
+
url: episodeImageUrl,
|
|
112
|
+
outputPath: outputImagePath,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (includeEpisodeTranscripts) {
|
|
118
|
+
const episodeTranscriptUrl = getTranscriptUrl(
|
|
119
|
+
item,
|
|
120
|
+
episodeTranscriptTypes
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (episodeTranscriptUrl) {
|
|
124
|
+
const episodeTranscriptFileExt = getUrlExt(episodeTranscriptUrl);
|
|
125
|
+
|
|
126
|
+
const episodeTranscriptName = getItemFilename({
|
|
127
|
+
item,
|
|
128
|
+
feed,
|
|
129
|
+
url: episodeAudioUrl,
|
|
130
|
+
ext: episodeTranscriptFileExt,
|
|
131
|
+
template: episodeTemplate,
|
|
132
|
+
width: episodeDigits,
|
|
133
|
+
offset: episodeNumOffset,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const outputTranscriptPath = path.resolve(
|
|
137
|
+
basePath,
|
|
138
|
+
episodeTranscriptName
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
item._episodeTranscript = {
|
|
142
|
+
url: episodeTranscriptUrl,
|
|
143
|
+
outputPath: outputTranscriptPath,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
items.push(item);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
i = next(i);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return limit ? items.slice(0, limit) : items;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const logItemsList = ({
|
|
158
|
+
type,
|
|
159
|
+
feed,
|
|
160
|
+
limit,
|
|
161
|
+
offset,
|
|
162
|
+
reverse,
|
|
163
|
+
before,
|
|
164
|
+
after,
|
|
165
|
+
episodeRegex,
|
|
166
|
+
episodeRegexExclude,
|
|
167
|
+
}) => {
|
|
168
|
+
const items = getItemsToDownload({
|
|
169
|
+
feed,
|
|
170
|
+
limit,
|
|
171
|
+
offset,
|
|
172
|
+
reverse,
|
|
173
|
+
before,
|
|
174
|
+
after,
|
|
175
|
+
episodeRegex,
|
|
176
|
+
episodeRegexExclude,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (!items.length) {
|
|
180
|
+
logErrorAndExit("No episodes found with provided criteria to list");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isJson = type === "json";
|
|
184
|
+
|
|
185
|
+
const output = items.map((item) => {
|
|
186
|
+
const data = {
|
|
187
|
+
episodeNum: feed.items.length - item._originalIndex,
|
|
188
|
+
title: item.title,
|
|
189
|
+
pubDate: item.pubDate,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return data;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (isJson) {
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.log(JSON.stringify(output));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.table(output);
|
|
203
|
+
};
|
package/bin/logger.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
2
|
|
|
3
|
-
const ERROR_STATUSES = {
|
|
3
|
+
export const ERROR_STATUSES = {
|
|
4
4
|
general: 1,
|
|
5
5
|
nothingDownloaded: 2,
|
|
6
6
|
completedWithErrors: 3,
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
const LOG_LEVEL_TYPES = {
|
|
9
|
+
export const LOG_LEVEL_TYPES = {
|
|
10
10
|
debug: "debug",
|
|
11
11
|
quiet: "quiet",
|
|
12
12
|
silent: "silent",
|
|
13
13
|
static: "static",
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const LOG_LEVELS = {
|
|
16
|
+
export const LOG_LEVELS = {
|
|
17
17
|
debug: 0,
|
|
18
18
|
info: 1,
|
|
19
19
|
important: 2,
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
const getShouldOutputProgressIndicator = () => {
|
|
22
|
+
export const getShouldOutputProgressIndicator = () => {
|
|
23
23
|
return (
|
|
24
24
|
process.stdout.isTTY &&
|
|
25
25
|
process.env.LOG_LEVEL !== LOG_LEVEL_TYPES.static &&
|
|
@@ -28,7 +28,7 @@ const getShouldOutputProgressIndicator = () => {
|
|
|
28
28
|
);
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
const logMessage = (message = "", logLevel = 1) => {
|
|
31
|
+
export const logMessage = (message = "", logLevel = 1) => {
|
|
32
32
|
if (
|
|
33
33
|
!process.env.LOG_LEVEL ||
|
|
34
34
|
process.env.LOG_LEVEL === LOG_LEVEL_TYPES.debug ||
|
|
@@ -51,7 +51,7 @@ const logMessage = (message = "", logLevel = 1) => {
|
|
|
51
51
|
}
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
const getLogMessageWithMarker = (marker) => {
|
|
54
|
+
export const getLogMessageWithMarker = (marker) => {
|
|
55
55
|
return (message, logLevel) => {
|
|
56
56
|
if (marker) {
|
|
57
57
|
logMessage(`${marker} | ${message}`, logLevel);
|
|
@@ -61,7 +61,7 @@ const getLogMessageWithMarker = (marker) => {
|
|
|
61
61
|
};
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
const logError = (msg, error) => {
|
|
64
|
+
export const logError = (msg, error) => {
|
|
65
65
|
if (process.env.LOG_LEVEL === LOG_LEVEL_TYPES.silent) {
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
@@ -73,7 +73,7 @@ const logError = (msg, error) => {
|
|
|
73
73
|
}
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
const logErrorAndExit = (msg, error) => {
|
|
76
|
+
export const logErrorAndExit = (msg, error) => {
|
|
77
77
|
console.error(msg);
|
|
78
78
|
|
|
79
79
|
if (error) {
|
|
@@ -82,13 +82,3 @@ const logErrorAndExit = (msg, error) => {
|
|
|
82
82
|
|
|
83
83
|
process.exit(ERROR_STATUSES.general);
|
|
84
84
|
};
|
|
85
|
-
|
|
86
|
-
export {
|
|
87
|
-
ERROR_STATUSES,
|
|
88
|
-
getShouldOutputProgressIndicator,
|
|
89
|
-
getLogMessageWithMarker,
|
|
90
|
-
LOG_LEVELS,
|
|
91
|
-
logMessage,
|
|
92
|
-
logError,
|
|
93
|
-
logErrorAndExit,
|
|
94
|
-
};
|
package/bin/meta.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { logMessage } from "./logger.js";
|
|
3
|
+
import { getPublicObject } from "./util.js";
|
|
4
|
+
|
|
5
|
+
export const writeFeedMeta = ({ outputPath, feed, override }) => {
|
|
6
|
+
const output = getPublicObject(feed, ["items"]);
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
if (override || !fs.existsSync(outputPath)) {
|
|
10
|
+
fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
|
|
11
|
+
} else {
|
|
12
|
+
logMessage("Feed metadata exists locally. Skipping...");
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Unable to save metadata file for feed: ${error.toString()}`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const writeItemMeta = ({ marker, outputPath, item, override }) => {
|
|
22
|
+
const output = getPublicObject(item);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
if (override || !fs.existsSync(outputPath)) {
|
|
26
|
+
fs.writeFileSync(outputPath, JSON.stringify(output, null, 4));
|
|
27
|
+
} else {
|
|
28
|
+
logMessage(`${marker} | Episode metadata exists locally. Skipping...`);
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error("Unable to save meta file for episode", error);
|
|
32
|
+
}
|
|
33
|
+
};
|
package/bin/naming.js
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import filenamify from "filenamify";
|
|
3
1
|
import dayjs from "dayjs";
|
|
2
|
+
import filenamify from "filenamify";
|
|
3
|
+
import path from "path";
|
|
4
4
|
|
|
5
5
|
const INVALID_CHAR_REPLACE = "_";
|
|
6
6
|
const MAX_LENGTH_FILENAME = process.env.MAX_LENGTH_FILENAME
|
|
7
7
|
? parseInt(process.env.MAX_LENGTH_FILENAME)
|
|
8
8
|
: 255;
|
|
9
9
|
|
|
10
|
-
const getSafeName = (name, maxLength = MAX_LENGTH_FILENAME) => {
|
|
10
|
+
export const getSafeName = (name, maxLength = MAX_LENGTH_FILENAME) => {
|
|
11
11
|
return filenamify(name, {
|
|
12
12
|
replacement: INVALID_CHAR_REPLACE,
|
|
13
13
|
maxLength,
|
|
14
14
|
});
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
const getSimpleFilename = (name, ext = "") => {
|
|
17
|
+
export const getSimpleFilename = (name, ext = "") => {
|
|
18
18
|
return `${getSafeName(name, MAX_LENGTH_FILENAME - (ext?.length ?? 0))}${ext}`;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
const getItemFilename = ({
|
|
21
|
+
export const getItemFilename = ({
|
|
22
22
|
item,
|
|
23
23
|
ext,
|
|
24
24
|
url,
|
|
@@ -92,7 +92,7 @@ const getItemFilename = ({
|
|
|
92
92
|
return nameSegments.join(path.sep);
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
const getFolderName = ({ feed, template }) => {
|
|
95
|
+
export const getFolderName = ({ feed, template }) => {
|
|
96
96
|
const templateReplacementsTuples = [
|
|
97
97
|
["podcast_title", feed.title || ""],
|
|
98
98
|
["podcast_link", feed.link || ""],
|
|
@@ -110,21 +110,3 @@ const getFolderName = ({ feed, template }) => {
|
|
|
110
110
|
|
|
111
111
|
return name;
|
|
112
112
|
};
|
|
113
|
-
|
|
114
|
-
const getArchiveFilename = ({ pubDate, name, ext }) => {
|
|
115
|
-
const formattedPubDate = pubDate
|
|
116
|
-
? dayjs(new Date(pubDate)).format("YYYYMMDD")
|
|
117
|
-
: null;
|
|
118
|
-
|
|
119
|
-
const baseName = formattedPubDate ? `${formattedPubDate}-${name}` : name;
|
|
120
|
-
|
|
121
|
-
return `${baseName}${ext}`;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
export {
|
|
125
|
-
getArchiveFilename,
|
|
126
|
-
getFolderName,
|
|
127
|
-
getItemFilename,
|
|
128
|
-
getSafeName,
|
|
129
|
-
getSimpleFilename,
|
|
130
|
-
};
|