podcast-dl 11.7.1 → 11.7.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/archive.js +33 -17
- package/bin/bin.js +6 -3
- package/bin/items.js +12 -14
- package/bin/meta.js +3 -3
- package/bin/naming.js +20 -24
- package/bin/util.js +21 -11
- package/bin/util.test.js +326 -0
- package/bin/validate.js +6 -1
- package/package.json +5 -2
package/bin/archive.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import dayjs from "dayjs";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import { getJsonFile } from "./util.js";
|
|
4
|
+
import { cwd, getJsonFile } from "./util.js";
|
|
5
|
+
|
|
6
|
+
const archiveCache = new Map();
|
|
7
|
+
|
|
8
|
+
const getArchiveData = (archivePath) => {
|
|
9
|
+
if (!archiveCache.has(archivePath)) {
|
|
10
|
+
const content = getJsonFile(archivePath);
|
|
11
|
+
archiveCache.set(archivePath, {
|
|
12
|
+
entries: new Set(content || []),
|
|
13
|
+
dirty: false,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return archiveCache.get(archivePath);
|
|
18
|
+
};
|
|
5
19
|
|
|
6
20
|
export const getArchiveKey = ({ prefix, name }) => {
|
|
7
21
|
return `${prefix}-${name}`;
|
|
@@ -14,30 +28,32 @@ export const getArchiveKeys = ({ prefix, name, guid }) => {
|
|
|
14
28
|
};
|
|
15
29
|
|
|
16
30
|
export const getArchive = (archive) => {
|
|
17
|
-
const
|
|
18
|
-
return
|
|
31
|
+
const { entries } = getArchiveData(archive);
|
|
32
|
+
return [...entries];
|
|
19
33
|
};
|
|
20
34
|
|
|
21
|
-
export const writeToArchive = ({
|
|
22
|
-
const
|
|
23
|
-
const archiveResult = getArchive(archive);
|
|
24
|
-
const keys = Array.from(
|
|
25
|
-
new Set([key, ...(archiveKeys || [])].filter(Boolean))
|
|
26
|
-
);
|
|
35
|
+
export const writeToArchive = ({ archiveKeys, archive }) => {
|
|
36
|
+
const data = getArchiveData(archive);
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
if (!
|
|
30
|
-
|
|
38
|
+
archiveKeys.forEach((archiveKey) => {
|
|
39
|
+
if (!data.entries.has(archiveKey)) {
|
|
40
|
+
data.entries.add(archiveKey);
|
|
41
|
+
data.dirty = true;
|
|
31
42
|
}
|
|
32
43
|
});
|
|
33
44
|
|
|
34
|
-
|
|
45
|
+
if (data.dirty) {
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
path.resolve(cwd, archive),
|
|
48
|
+
JSON.stringify([...data.entries], null, 4)
|
|
49
|
+
);
|
|
50
|
+
data.dirty = false;
|
|
51
|
+
}
|
|
35
52
|
};
|
|
36
53
|
|
|
37
|
-
export const getIsInArchive = ({
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
return keys.some((archiveKey) => archiveResult.includes(archiveKey));
|
|
54
|
+
export const getIsInArchive = ({ archiveKeys, archive }) => {
|
|
55
|
+
const { entries } = getArchiveData(archive);
|
|
56
|
+
return archiveKeys.some((archiveKey) => entries.has(archiveKey));
|
|
41
57
|
};
|
|
42
58
|
|
|
43
59
|
export const getArchiveFilename = ({ pubDate, name, ext }) => {
|
package/bin/bin.js
CHANGED
|
@@ -19,11 +19,13 @@ import {
|
|
|
19
19
|
import { writeFeedMeta } from "./meta.js";
|
|
20
20
|
import { getFolderName, getSimpleFilename } from "./naming.js";
|
|
21
21
|
import {
|
|
22
|
+
cwd,
|
|
22
23
|
getFileFeed,
|
|
23
24
|
getImageUrl,
|
|
24
25
|
getUrlExt,
|
|
25
26
|
getUrlFeed,
|
|
26
27
|
logFeedInfo,
|
|
28
|
+
normalizeUrl,
|
|
27
29
|
} from "./util.js";
|
|
28
30
|
|
|
29
31
|
const opts = setupCommander(program);
|
|
@@ -91,7 +93,8 @@ const main = async () => {
|
|
|
91
93
|
|
|
92
94
|
const archivePrefix = (() => {
|
|
93
95
|
if (feed.feedUrl || url) {
|
|
94
|
-
const
|
|
96
|
+
const raw = feed.feedUrl || url;
|
|
97
|
+
const { hostname, pathname } = new URL(normalizeUrl(raw) ?? raw);
|
|
95
98
|
return `${hostname}${pathname}`;
|
|
96
99
|
}
|
|
97
100
|
|
|
@@ -99,7 +102,7 @@ const main = async () => {
|
|
|
99
102
|
})();
|
|
100
103
|
|
|
101
104
|
const basePath = _path.resolve(
|
|
102
|
-
|
|
105
|
+
cwd,
|
|
103
106
|
getFolderName({ feed, template: outDir })
|
|
104
107
|
);
|
|
105
108
|
|
|
@@ -147,7 +150,7 @@ const main = async () => {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
if (includeMeta) {
|
|
150
|
-
const podcastImageUrl = getImageUrl(feed);
|
|
153
|
+
const podcastImageUrl = normalizeUrl(getImageUrl(feed));
|
|
151
154
|
|
|
152
155
|
if (podcastImageUrl) {
|
|
153
156
|
const podcastImageFileExt = getUrlExt(podcastImageUrl);
|
package/bin/items.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getLoopControls,
|
|
10
10
|
getTranscriptUrl,
|
|
11
11
|
getUrlExt,
|
|
12
|
+
normalizeUrl,
|
|
12
13
|
} from "./util.js";
|
|
13
14
|
|
|
14
15
|
export const ITEM_LIST_FORMATS = ["table", "json"];
|
|
@@ -46,6 +47,10 @@ export const getItemsToDownload = ({
|
|
|
46
47
|
const items = [];
|
|
47
48
|
|
|
48
49
|
const savedArchive = archive ? getArchive(archive) : [];
|
|
50
|
+
const includeRegex = episodeRegex ? new RegExp(episodeRegex) : null;
|
|
51
|
+
const excludeRegex = episodeRegexExclude
|
|
52
|
+
? new RegExp(episodeRegexExclude)
|
|
53
|
+
: null;
|
|
49
54
|
|
|
50
55
|
while (shouldGo(i)) {
|
|
51
56
|
const { title, pubDate, itunes, guid } = feed.items[i];
|
|
@@ -53,18 +58,12 @@ export const getItemsToDownload = ({
|
|
|
53
58
|
const pubDateDay = dayjs(new Date(pubDate));
|
|
54
59
|
let isValid = true;
|
|
55
60
|
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
if (title && !generatedEpisodeRegex.test(title)) {
|
|
59
|
-
isValid = false;
|
|
60
|
-
}
|
|
61
|
+
if (includeRegex && title && !includeRegex.test(title)) {
|
|
62
|
+
isValid = false;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
if (title && generatedEpisodeRegexExclude.test(title)) {
|
|
66
|
-
isValid = false;
|
|
67
|
-
}
|
|
65
|
+
if (excludeRegex && title && excludeRegex.test(title)) {
|
|
66
|
+
isValid = false;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
if (before) {
|
|
@@ -115,7 +114,7 @@ export const getItemsToDownload = ({
|
|
|
115
114
|
item._archiveKeys = archiveKeys;
|
|
116
115
|
|
|
117
116
|
if (includeEpisodeImages || embedMetadataFlag) {
|
|
118
|
-
const episodeImageUrl = getImageUrl(item);
|
|
117
|
+
const episodeImageUrl = normalizeUrl(getImageUrl(item));
|
|
119
118
|
|
|
120
119
|
if (episodeImageUrl) {
|
|
121
120
|
const episodeImageFileExt = getUrlExt(episodeImageUrl);
|
|
@@ -150,9 +149,8 @@ export const getItemsToDownload = ({
|
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
if (includeEpisodeTranscripts) {
|
|
153
|
-
const episodeTranscriptUrl =
|
|
154
|
-
item,
|
|
155
|
-
episodeTranscriptTypes
|
|
152
|
+
const episodeTranscriptUrl = normalizeUrl(
|
|
153
|
+
getTranscriptUrl(item, episodeTranscriptTypes)
|
|
156
154
|
);
|
|
157
155
|
|
|
158
156
|
if (episodeTranscriptUrl) {
|
package/bin/meta.js
CHANGED
|
@@ -4,7 +4,7 @@ import { logMessage } from "./logger.js";
|
|
|
4
4
|
import { getPublicObject } from "./util.js";
|
|
5
5
|
|
|
6
6
|
export const writeFeedMeta = ({ outputPath, feed, key, archive, override }) => {
|
|
7
|
-
if (key && archive && getIsInArchive({ key, archive })) {
|
|
7
|
+
if (key && archive && getIsInArchive({ archiveKeys: [key], archive })) {
|
|
8
8
|
logMessage("Feed metadata exists in archive. Skipping...");
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
@@ -17,9 +17,9 @@ export const writeFeedMeta = ({ outputPath, feed, key, archive, override }) => {
|
|
|
17
17
|
logMessage("Feed metadata exists locally. Skipping...");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
if (key && archive && !getIsInArchive({ key, archive })) {
|
|
20
|
+
if (key && archive && !getIsInArchive({ archiveKeys: [key], archive })) {
|
|
21
21
|
try {
|
|
22
|
-
writeToArchive({ key, archive });
|
|
22
|
+
writeToArchive({ archiveKeys: [key], archive });
|
|
23
23
|
} catch (error) {
|
|
24
24
|
throw new Error(`Error writing to archive: ${error.toString()}`);
|
|
25
25
|
}
|
package/bin/naming.js
CHANGED
|
@@ -33,6 +33,9 @@ const applyFilters = (value, filterStr) => {
|
|
|
33
33
|
}, value);
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
const TEMPLATE_REGEX = /{{(\w+)(\|[^}]+)?}}/g;
|
|
37
|
+
const customRegexCache = new Map();
|
|
38
|
+
|
|
36
39
|
const MAX_LENGTH_FILENAME = process.env.MAX_LENGTH_FILENAME
|
|
37
40
|
? parseInt(process.env.MAX_LENGTH_FILENAME)
|
|
38
41
|
: 255;
|
|
@@ -66,25 +69,17 @@ export const getItemFilename = ({
|
|
|
66
69
|
const episodeNum = feed.items.length - item._originalIndex + offset;
|
|
67
70
|
const title = item.title || "";
|
|
68
71
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
? dayjs(new Date(item.pubDate)).format("MM")
|
|
75
|
-
: null;
|
|
76
|
-
|
|
77
|
-
const releaseDay = item.pubDate
|
|
78
|
-
? dayjs(new Date(item.pubDate)).format("DD")
|
|
79
|
-
: null;
|
|
80
|
-
|
|
81
|
-
const releaseDate = item.pubDate
|
|
82
|
-
? dayjs(new Date(item.pubDate)).format("YYYYMMDD")
|
|
83
|
-
: null;
|
|
72
|
+
const pubDateParsed = item.pubDate ? dayjs(new Date(item.pubDate)) : null;
|
|
73
|
+
const releaseYear = pubDateParsed?.format("YYYY") ?? null;
|
|
74
|
+
const releaseMonth = pubDateParsed?.format("MM") ?? null;
|
|
75
|
+
const releaseDay = pubDateParsed?.format("DD") ?? null;
|
|
76
|
+
const releaseDate = pubDateParsed?.format("YYYYMMDD") ?? null;
|
|
84
77
|
|
|
85
78
|
const customReplacementTuples = customTemplateOptions.map((option, i) => {
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
if (!customRegexCache.has(option)) {
|
|
80
|
+
customRegexCache.set(option, new RegExp(option));
|
|
81
|
+
}
|
|
82
|
+
const match = title.match(customRegexCache.get(option));
|
|
88
83
|
|
|
89
84
|
return match && match[0] ? [`custom_${i}`, match[0]] : [`custom_${i}`, ""];
|
|
90
85
|
});
|
|
@@ -107,11 +102,13 @@ export const getItemFilename = ({
|
|
|
107
102
|
const replacementsMap = Object.fromEntries(templateReplacementsTuples);
|
|
108
103
|
const templateSegments = template.trim().split(path.sep);
|
|
109
104
|
const nameSegments = templateSegments.map((segment, index) => {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
105
|
+
const name = segment.replace(
|
|
106
|
+
TEMPLATE_REGEX,
|
|
107
|
+
(match, varName, filterStr) => {
|
|
108
|
+
const replacement = replacementsMap[varName] || "";
|
|
109
|
+
return applyFilters(replacement, filterStr);
|
|
110
|
+
}
|
|
111
|
+
);
|
|
115
112
|
|
|
116
113
|
// Only truncate non-final segments here (they don't get an extension)
|
|
117
114
|
// Final segment is truncated below with the extension accounted for
|
|
@@ -133,8 +130,7 @@ export const getFolderName = ({ feed, template }) => {
|
|
|
133
130
|
podcast_link: feed.link || "",
|
|
134
131
|
};
|
|
135
132
|
|
|
136
|
-
const
|
|
137
|
-
const name = template.replace(replaceRegex, (_, varName, filterStr) => {
|
|
133
|
+
const name = template.replace(TEMPLATE_REGEX, (_, varName, filterStr) => {
|
|
138
134
|
const replacement = replacementsMap[varName] || "";
|
|
139
135
|
const filtered = applyFilters(replacement, filterStr);
|
|
140
136
|
return getSafeName(filtered);
|
package/bin/util.js
CHANGED
|
@@ -14,6 +14,7 @@ export const AUDIO_FORMATS = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export const isWin = process.platform === "win32";
|
|
17
|
+
export const cwd = process.cwd();
|
|
17
18
|
|
|
18
19
|
export const defaultRssParserConfig = {
|
|
19
20
|
defaultRSS: 2.0,
|
|
@@ -32,7 +33,7 @@ export const escapeArgForShell = (arg) => {
|
|
|
32
33
|
let result = arg;
|
|
33
34
|
|
|
34
35
|
if (/[^A-Za-z0-9_/:=-]/.test(result)) {
|
|
35
|
-
if (
|
|
36
|
+
if (process.platform === "win32") {
|
|
36
37
|
return `"${result}"`;
|
|
37
38
|
} else {
|
|
38
39
|
result = "'" + result.replace(/'/g, "'\\''") + "'";
|
|
@@ -72,7 +73,7 @@ export const getPublicObject = (object, exclude = []) => {
|
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
export const getFileString = (filePath) => {
|
|
75
|
-
const fullPath = path.resolve(
|
|
76
|
+
const fullPath = path.resolve(cwd, filePath);
|
|
76
77
|
|
|
77
78
|
if (!fs.existsSync(fullPath)) {
|
|
78
79
|
return null;
|
|
@@ -88,7 +89,7 @@ export const getFileString = (filePath) => {
|
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
export const getJsonFile = (filePath) => {
|
|
91
|
-
const fullPath = path.resolve(
|
|
92
|
+
const fullPath = path.resolve(cwd, filePath);
|
|
92
93
|
|
|
93
94
|
if (!fs.existsSync(fullPath)) {
|
|
94
95
|
return null;
|
|
@@ -135,11 +136,16 @@ export const logFeedInfo = (feed) => {
|
|
|
135
136
|
logMessage();
|
|
136
137
|
};
|
|
137
138
|
|
|
139
|
+
export const normalizeUrl = (url) =>
|
|
140
|
+
url?.startsWith("//") ? "https:" + url : url;
|
|
141
|
+
|
|
138
142
|
export const getUrlExt = (url) => {
|
|
139
143
|
if (!url) {
|
|
140
144
|
return "";
|
|
141
145
|
}
|
|
142
146
|
|
|
147
|
+
url = normalizeUrl(url);
|
|
148
|
+
|
|
143
149
|
const { pathname } = new URL(url);
|
|
144
150
|
|
|
145
151
|
if (!pathname) {
|
|
@@ -273,10 +279,6 @@ export const correctExtensionFromMime = ({
|
|
|
273
279
|
return basePath + mimeExt;
|
|
274
280
|
};
|
|
275
281
|
|
|
276
|
-
export const VALID_AUDIO_EXTS = [
|
|
277
|
-
...new Set(Object.values(AUDIO_TYPES_TO_EXTS)),
|
|
278
|
-
];
|
|
279
|
-
|
|
280
282
|
export const getIsAudioUrl = (url) => {
|
|
281
283
|
let ext;
|
|
282
284
|
try {
|
|
@@ -289,7 +291,7 @@ export const getIsAudioUrl = (url) => {
|
|
|
289
291
|
return false;
|
|
290
292
|
}
|
|
291
293
|
|
|
292
|
-
return
|
|
294
|
+
return VALID_AUDIO_EXTS_SET.has(ext);
|
|
293
295
|
};
|
|
294
296
|
|
|
295
297
|
export const AUDIO_ORDER_TYPES = {
|
|
@@ -303,16 +305,22 @@ export const getEpisodeAudioUrlAndExt = (
|
|
|
303
305
|
) => {
|
|
304
306
|
for (const source of order) {
|
|
305
307
|
if (source === AUDIO_ORDER_TYPES.link && link && getIsAudioUrl(link)) {
|
|
306
|
-
return { url: link, ext: getUrlExt(link) };
|
|
308
|
+
return { url: normalizeUrl(link), ext: getUrlExt(link) };
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
if (source === AUDIO_ORDER_TYPES.enclosure && enclosure) {
|
|
310
312
|
if (getIsAudioUrl(enclosure.url)) {
|
|
311
|
-
return {
|
|
313
|
+
return {
|
|
314
|
+
url: normalizeUrl(enclosure.url),
|
|
315
|
+
ext: getUrlExt(enclosure.url),
|
|
316
|
+
};
|
|
312
317
|
}
|
|
313
318
|
|
|
314
319
|
if (enclosure.url && AUDIO_TYPES_TO_EXTS[enclosure.type]) {
|
|
315
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
url: normalizeUrl(enclosure.url),
|
|
322
|
+
ext: AUDIO_TYPES_TO_EXTS[enclosure.type],
|
|
323
|
+
};
|
|
316
324
|
}
|
|
317
325
|
}
|
|
318
326
|
}
|
|
@@ -389,6 +397,8 @@ export const getFileFeed = async (filePath, parserConfig) => {
|
|
|
389
397
|
};
|
|
390
398
|
|
|
391
399
|
export const getUrlFeed = async (url, parserConfig) => {
|
|
400
|
+
url = normalizeUrl(url) ?? url;
|
|
401
|
+
|
|
392
402
|
const config = parserConfig
|
|
393
403
|
? getJsonFile(parserConfig)
|
|
394
404
|
: defaultRssParserConfig;
|
package/bin/util.test.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
escapeArgForShell,
|
|
4
|
+
getTempPath,
|
|
5
|
+
getPublicObject,
|
|
6
|
+
getLoopControls,
|
|
7
|
+
normalizeUrl,
|
|
8
|
+
getUrlExt,
|
|
9
|
+
getExtFromMime,
|
|
10
|
+
correctExtensionFromMime,
|
|
11
|
+
getIsAudioUrl,
|
|
12
|
+
AUDIO_ORDER_TYPES,
|
|
13
|
+
getEpisodeAudioUrlAndExt,
|
|
14
|
+
getImageUrl,
|
|
15
|
+
getTranscriptUrl,
|
|
16
|
+
TRANSCRIPT_TYPES,
|
|
17
|
+
} from "./util.js";
|
|
18
|
+
|
|
19
|
+
describe("escapeArgForShell", () => {
|
|
20
|
+
describe("on Unix", () => {
|
|
21
|
+
const originalPlatform = process.platform;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
Object.defineProperty(process, "platform", {
|
|
24
|
+
value: "linux",
|
|
25
|
+
configurable: true,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
Object.defineProperty(process, "platform", {
|
|
31
|
+
value: originalPlatform,
|
|
32
|
+
configurable: true,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns arg unchanged when only safe chars", () => {
|
|
37
|
+
expect(escapeArgForShell("foo")).toBe("foo");
|
|
38
|
+
expect(escapeArgForShell("a1_/:=-bar")).toBe("a1_/:=-bar");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("wraps in single quotes when unsafe chars", () => {
|
|
42
|
+
expect(escapeArgForShell("foo bar")).toBe("'foo bar'");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("escapes single quotes inside", () => {
|
|
46
|
+
expect(escapeArgForShell("it's")).toBe("'it'\\''s'");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("on Windows", () => {
|
|
51
|
+
const originalPlatform = process.platform;
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
Object.defineProperty(process, "platform", {
|
|
54
|
+
value: "win32",
|
|
55
|
+
configurable: true,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
Object.defineProperty(process, "platform", {
|
|
61
|
+
value: originalPlatform,
|
|
62
|
+
configurable: true,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns arg unchanged when only safe chars", () => {
|
|
67
|
+
expect(escapeArgForShell("foo")).toBe("foo");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("wraps in double quotes when unsafe chars", () => {
|
|
71
|
+
expect(escapeArgForShell("foo bar")).toBe('"foo bar"');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("getTempPath", () => {
|
|
77
|
+
it("appends .tmp to path", () => {
|
|
78
|
+
expect(getTempPath("/tmp/file.mp3")).toBe("/tmp/file.mp3.tmp");
|
|
79
|
+
expect(getTempPath("out")).toBe("out.tmp");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("normalizeUrl", () => {
|
|
84
|
+
it("prepends https: for protocol-relative URL", () => {
|
|
85
|
+
expect(normalizeUrl("//example.com/foo")).toBe("https://example.com/foo");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns url unchanged otherwise", () => {
|
|
89
|
+
expect(normalizeUrl("https://example.com")).toBe("https://example.com");
|
|
90
|
+
expect(normalizeUrl("http://a.b")).toBe("http://a.b");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns undefined for undefined", () => {
|
|
94
|
+
expect(normalizeUrl(undefined)).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("getUrlExt", () => {
|
|
99
|
+
it("returns extension from pathname", () => {
|
|
100
|
+
expect(getUrlExt("https://example.com/episode.mp3")).toBe(".mp3");
|
|
101
|
+
expect(getUrlExt("https://example.com/path/file.m4a?q=1")).toBe(".m4a");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns empty string for no url", () => {
|
|
105
|
+
expect(getUrlExt(null)).toBe("");
|
|
106
|
+
expect(getUrlExt("")).toBe("");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns empty string when pathname has no ext", () => {
|
|
110
|
+
expect(getUrlExt("https://example.com/")).toBe("");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("getPublicObject", () => {
|
|
115
|
+
it("omits keys starting with _", () => {
|
|
116
|
+
expect(getPublicObject({ a: 1, _b: 2 })).toEqual({ a: 1 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("omits keys in exclude", () => {
|
|
120
|
+
expect(getPublicObject({ a: 1, b: 2 }, ["b"])).toEqual({ a: 1 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("omits falsy values", () => {
|
|
124
|
+
expect(getPublicObject({ a: 1, b: 0, c: null })).toEqual({ a: 1 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns empty object when all filtered", () => {
|
|
128
|
+
expect(getPublicObject({ _x: 1 }, [])).toEqual({});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("getLoopControls", () => {
|
|
133
|
+
describe("forward", () => {
|
|
134
|
+
it("starts at offset, goes to length", () => {
|
|
135
|
+
const c = getLoopControls({ offset: 1, length: 5, reverse: false });
|
|
136
|
+
expect(c.startIndex).toBe(1);
|
|
137
|
+
expect(c.shouldGo(0)).toBe(true);
|
|
138
|
+
expect(c.shouldGo(4)).toBe(true);
|
|
139
|
+
expect(c.shouldGo(5)).toBe(false);
|
|
140
|
+
expect(c.next(2)).toBe(3);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("reverse", () => {
|
|
145
|
+
it("starts at length - 1 - offset, goes down to 0", () => {
|
|
146
|
+
const c = getLoopControls({ offset: 1, length: 5, reverse: true });
|
|
147
|
+
expect(c.startIndex).toBe(3);
|
|
148
|
+
expect(c.shouldGo(3)).toBe(true);
|
|
149
|
+
expect(c.shouldGo(0)).toBe(true);
|
|
150
|
+
expect(c.shouldGo(-1)).toBe(false);
|
|
151
|
+
expect(c.next(2)).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("getExtFromMime", () => {
|
|
157
|
+
it("returns extension for known MIME", () => {
|
|
158
|
+
expect(getExtFromMime("audio/mpeg")).toBe(".mp3");
|
|
159
|
+
expect(getExtFromMime("text/vtt")).toBe(".vtt");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns null for unknown MIME", () => {
|
|
163
|
+
expect(getExtFromMime("application/unknown")).toBeNull();
|
|
164
|
+
expect(getExtFromMime(null)).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("getIsAudioUrl", () => {
|
|
169
|
+
it("returns true for URL with audio extension", () => {
|
|
170
|
+
expect(getIsAudioUrl("https://example.com/ep.mp3")).toBe(true);
|
|
171
|
+
expect(getIsAudioUrl("https://example.com/ep.m4a")).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns false for URL without audio extension", () => {
|
|
175
|
+
expect(getIsAudioUrl("https://example.com/page.html")).toBe(false);
|
|
176
|
+
expect(getIsAudioUrl("https://example.com/")).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns false for invalid URL", () => {
|
|
180
|
+
expect(getIsAudioUrl("not-a-url")).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("correctExtensionFromMime", () => {
|
|
185
|
+
it("returns path unchanged when MIME ext matches current ext", () => {
|
|
186
|
+
expect(
|
|
187
|
+
correctExtensionFromMime({
|
|
188
|
+
outputPath: "/out/episode.mp3",
|
|
189
|
+
contentType: "audio/mpeg",
|
|
190
|
+
})
|
|
191
|
+
).toBe("/out/episode.mp3");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("replaces extension when MIME suggests different ext", () => {
|
|
195
|
+
expect(
|
|
196
|
+
correctExtensionFromMime({
|
|
197
|
+
outputPath: "/out/episode.mp3",
|
|
198
|
+
contentType: "audio/mp4",
|
|
199
|
+
})
|
|
200
|
+
).toBe("/out/episode.m4a");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("calls onCorrect when extension is corrected", () => {
|
|
204
|
+
const onCorrect = vi.fn();
|
|
205
|
+
correctExtensionFromMime({
|
|
206
|
+
outputPath: "/out/ep.mp3",
|
|
207
|
+
contentType: "audio/mp4",
|
|
208
|
+
onCorrect,
|
|
209
|
+
});
|
|
210
|
+
expect(onCorrect).toHaveBeenCalledWith(".mp3", ".m4a");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns path unchanged when contentType has no known MIME", () => {
|
|
214
|
+
expect(
|
|
215
|
+
correctExtensionFromMime({
|
|
216
|
+
outputPath: "/out/ep.mp3",
|
|
217
|
+
contentType: "application/octet-stream",
|
|
218
|
+
})
|
|
219
|
+
).toBe("/out/ep.mp3");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("getEpisodeAudioUrlAndExt", () => {
|
|
224
|
+
it("prefers enclosure URL when present and audio", () => {
|
|
225
|
+
const result = getEpisodeAudioUrlAndExt({
|
|
226
|
+
enclosure: { url: "https://example.com/ep.mp3" },
|
|
227
|
+
link: "https://example.com/other.mp3",
|
|
228
|
+
});
|
|
229
|
+
expect(result).toEqual({ url: "https://example.com/ep.mp3", ext: ".mp3" });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("uses enclosure type when URL has no audio ext", () => {
|
|
233
|
+
const result = getEpisodeAudioUrlAndExt({
|
|
234
|
+
enclosure: { url: "https://example.com/ep", type: "audio/mpeg" },
|
|
235
|
+
});
|
|
236
|
+
expect(result).toEqual({ url: "https://example.com/ep", ext: ".mp3" });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("falls back to link when enclosure not audio", () => {
|
|
240
|
+
const result = getEpisodeAudioUrlAndExt({
|
|
241
|
+
enclosure: { url: "https://example.com/page.html" },
|
|
242
|
+
link: "https://example.com/ep.mp3",
|
|
243
|
+
});
|
|
244
|
+
expect(result).toEqual({ url: "https://example.com/ep.mp3", ext: ".mp3" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("returns null url/ext when no audio source", () => {
|
|
248
|
+
const result = getEpisodeAudioUrlAndExt({
|
|
249
|
+
enclosure: { url: "https://example.com/page.html" },
|
|
250
|
+
});
|
|
251
|
+
expect(result).toEqual({ url: null, ext: null });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("respects order option", () => {
|
|
255
|
+
const result = getEpisodeAudioUrlAndExt(
|
|
256
|
+
{
|
|
257
|
+
enclosure: { url: "https://example.com/ep.mp3" },
|
|
258
|
+
link: "https://example.com/alt.mp3",
|
|
259
|
+
},
|
|
260
|
+
[AUDIO_ORDER_TYPES.link, AUDIO_ORDER_TYPES.enclosure]
|
|
261
|
+
);
|
|
262
|
+
expect(result).toEqual({ url: "https://example.com/alt.mp3", ext: ".mp3" });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("getImageUrl", () => {
|
|
267
|
+
it("prefers image.url", () => {
|
|
268
|
+
expect(
|
|
269
|
+
getImageUrl({
|
|
270
|
+
image: { url: "https://a.com/img.jpg" },
|
|
271
|
+
itunes: { image: "https://b.com/img.jpg" },
|
|
272
|
+
})
|
|
273
|
+
).toBe("https://a.com/img.jpg");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("uses image.link when image.url missing", () => {
|
|
277
|
+
expect(
|
|
278
|
+
getImageUrl({
|
|
279
|
+
image: { link: "https://a.com/img.jpg" },
|
|
280
|
+
itunes: { image: "https://b.com/img.jpg" },
|
|
281
|
+
})
|
|
282
|
+
).toBe("https://a.com/img.jpg");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("uses itunes.image when image url/link missing", () => {
|
|
286
|
+
expect(getImageUrl({ itunes: { image: "https://b.com/img.jpg" } })).toBe(
|
|
287
|
+
"https://b.com/img.jpg"
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("returns null when no image", () => {
|
|
292
|
+
expect(getImageUrl({})).toBeNull();
|
|
293
|
+
expect(getImageUrl({ image: {}, itunes: {} })).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("getTranscriptUrl", () => {
|
|
298
|
+
it("returns null when no podcastTranscripts", () => {
|
|
299
|
+
expect(getTranscriptUrl({})).toBeNull();
|
|
300
|
+
expect(getTranscriptUrl({ podcastTranscripts: [] })).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("returns URL for first matching transcript type", () => {
|
|
304
|
+
const item = {
|
|
305
|
+
podcastTranscripts: [
|
|
306
|
+
{ $: { type: "text/plain", url: "https://example.com/plain.txt" } },
|
|
307
|
+
{ $: { type: "text/vtt", url: "https://example.com/cc.vtt" } },
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
expect(
|
|
311
|
+
getTranscriptUrl(item, [
|
|
312
|
+
TRANSCRIPT_TYPES["text/vtt"],
|
|
313
|
+
TRANSCRIPT_TYPES["text/plain"],
|
|
314
|
+
])
|
|
315
|
+
).toBe("https://example.com/cc.vtt");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("returns null when type does not match", () => {
|
|
319
|
+
const item = {
|
|
320
|
+
podcastTranscripts: [
|
|
321
|
+
{ $: { type: "text/plain", url: "https://example.com/plain.txt" } },
|
|
322
|
+
],
|
|
323
|
+
};
|
|
324
|
+
expect(getTranscriptUrl(item, ["text/vtt"])).toBeNull();
|
|
325
|
+
});
|
|
326
|
+
});
|
package/bin/validate.js
CHANGED
|
@@ -32,8 +32,13 @@ export const createParseNumber = ({ min, max, name, required = true }) => {
|
|
|
32
32
|
};
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
let ffmpegExists = null;
|
|
35
36
|
export const hasFfmpeg = (value) => {
|
|
36
|
-
if (
|
|
37
|
+
if (ffmpegExists === null) {
|
|
38
|
+
ffmpegExists = commandExistsSync("ffmpeg");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!ffmpegExists) {
|
|
37
42
|
logErrorAndExit('option specified requires "ffmpeg" be available');
|
|
38
43
|
}
|
|
39
44
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "podcast-dl",
|
|
3
|
-
"version": "11.7.
|
|
3
|
+
"version": "11.7.3",
|
|
4
4
|
"description": "A CLI for downloading podcasts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./bin/bin.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "rimraf ./binaries && rimraf ./dist && node build.cjs",
|
|
9
|
-
"lint": "eslint ./bin"
|
|
9
|
+
"lint": "eslint ./bin",
|
|
10
|
+
"test": "vitest run",
|
|
11
|
+
"test:watch": "vitest"
|
|
10
12
|
},
|
|
11
13
|
"lint-staged": {
|
|
12
14
|
"*.{js,json,md}": [
|
|
@@ -47,6 +49,7 @@
|
|
|
47
49
|
"lint-staged": "^10.1.7",
|
|
48
50
|
"prettier": "2.3.2",
|
|
49
51
|
"rimraf": "^3.0.2",
|
|
52
|
+
"vitest": "^3.2.4",
|
|
50
53
|
"webpack": "^5.75.0"
|
|
51
54
|
},
|
|
52
55
|
"dependencies": {
|