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 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 archiveContent = getJsonFile(archive);
18
- return archiveContent === null ? [] : archiveContent;
31
+ const { entries } = getArchiveData(archive);
32
+ return [...entries];
19
33
  };
20
34
 
21
- export const writeToArchive = ({ key, archiveKeys, archive }) => {
22
- const archivePath = path.resolve(process.cwd(), archive);
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
- keys.forEach((archiveKey) => {
29
- if (!archiveResult.includes(archiveKey)) {
30
- archiveResult.push(archiveKey);
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
- fs.writeFileSync(archivePath, JSON.stringify(archiveResult, null, 4));
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 = ({ key, archiveKeys, archive }) => {
38
- const archiveResult = getArchive(archive);
39
- const keys = [key, ...(archiveKeys || [])].filter(Boolean);
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 { hostname, pathname } = new URL(feed.feedUrl || url);
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
- process.cwd(),
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 (episodeRegex) {
57
- const generatedEpisodeRegex = new RegExp(episodeRegex);
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 (episodeRegexExclude) {
64
- const generatedEpisodeRegexExclude = new RegExp(episodeRegexExclude);
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 = getTranscriptUrl(
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 releaseYear = item.pubDate
70
- ? dayjs(new Date(item.pubDate)).format("YYYY")
71
- : null;
72
-
73
- const releaseMonth = item.pubDate
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
- const matchRegex = new RegExp(option);
87
- const match = title.match(matchRegex);
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 replaceRegex = /{{(\w+)(\|[^}]+)?}}/g;
111
- const name = segment.replace(replaceRegex, (match, varName, filterStr) => {
112
- const replacement = replacementsMap[varName] || "";
113
- return applyFilters(replacement, filterStr);
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 replaceRegex = /{{(\w+)(\|[^}]+)?}}/g;
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 (isWin) {
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(process.cwd(), filePath);
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(process.cwd(), filePath);
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 VALID_AUDIO_EXTS.includes(ext);
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 { url: enclosure.url, ext: getUrlExt(enclosure.url) };
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 { url: enclosure.url, ext: AUDIO_TYPES_TO_EXTS[enclosure.type] };
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;
@@ -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 (!commandExistsSync("ffmpeg")) {
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.1",
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": {