podcast-dl 11.4.1 → 11.5.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 CHANGED
@@ -1,146 +1,147 @@
1
- # podcast-dl
2
-
3
- A humble CLI for downloading and archiving podcasts.
4
-
5
- ## How to Use
6
-
7
- ### npx
8
-
9
- **[Node Required](https://nodejs.org/en/)**
10
-
11
- `npx podcast-dl --url <PODCAST_RSS_URL>`
12
-
13
- ### Binaries
14
-
15
- [Visit the releases page](https://github.com/lightpohl/podcast-dl/releases) and download the latest binary for your system.
16
-
17
- `podcast-dl --url <PODCAST_RSS_URL>`
18
-
19
- ### [More Examples](./docs/examples.md)
20
-
21
- ## Options
22
-
23
- Either `--url` or `--file` must be provided.
24
-
25
- Type values surrounded in square brackets (`[]`) can be used as boolean options (no argument required).
26
-
27
- | Option | Type | Required | Description |
28
- | --------------------------------- | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
- | --url | String | true\* | URL to podcast RSS feed. |
30
- | --file | String | true\* | Path to local RSS file. |
31
- | --out-dir | String | false | Specify output directory for episodes and metadata. Defaults to `"./{{podcast_title}}"`. See "Template Options" for more details. |
32
- | --threads | Number | false | Determines the number of downloads that will happen concurrently. Default is `1`. |
33
- | --attempts | Number | false | Sets the number of download attempts per individual file. Default is `3`. |
34
- | --archive | [String] | false | Download or write out items not listed in archive file. Generates archive file at path if not found. Defaults to `"./{{podcast_title}}/archive.json"` when used as a boolean option. See "Template Options" for more details. |
35
- | --episode-template | String | false | Template for generating episode related filenames. See "Template Options" for details. |
36
- | --episode-custom-template-options | <String...> | false | Provide custom options for the episode template. See "Template Options" for details. |
37
- | --include-meta | | false | Write out podcast metadata to JSON. |
38
- | --include-episode-meta | | false | Write out individual episode metadata to JSON. |
39
- | --include-episode-images | | false | Download found episode images. |
40
- | --include-episode-transcripts | | false | Download found episode transcripts. |
41
- | --offset | Number | false | Offset starting download position. Default is `0`. |
42
- | --limit | Number | false | Max number of episodes to download. Downloads all by default. |
43
- | --after | String | false | Only download episodes after this date (i.e. MM/DD/YYYY, inclusive). |
44
- | --before | String | false | Only download episodes before this date (i.e. MM/DD/YYYY, inclusive). |
45
- | --episode-regex | String | false | Match episode title against provided regex before starting download. |
46
- | --episode-regex-exclude | String | false | Episode titles matching provided regex will be excluded. |
47
- | --episode-digits | Number | false | Minimum number of digits to use for episode numbering (e.g. 3 would generate "001" instead of "1"). Default is `1`. |
48
- | --episode-num-offset | Number | false | Offset the acquired episode number. Default is `0`. |
49
- | --episode-source-order | String | false | Attempted order to extract episode audio URL from RSS feed. Default is `"enclosure,link"`. |
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
- | --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
- | --add-mp3-metadata | | false | Attempts to add a base level of episode metadata to each episode. Recommended only in cases where the original metadata is of poor quality. (**ffmpeg required**) |
53
- | --adjust-bitrate | String (e.g. "48k") | false | Attempts to adjust bitrate of episodes. (**ffmpeg required**) |
54
- | --mono | | false | Attempts to force episodes into mono. (**ffmpeg required**) |
55
- | --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 `--add-mp3-metadata`, `--adjust-bitrate`, `--mono`, and `--exec`. |
57
- | --reverse | | false | Reverse download direction and start at last RSS item. |
58
- | --info | | false | Print retrieved podcast info instead of downloading. |
59
- | --list | [String] | false | Print episode list instead of downloading. Defaults to `"table"` when used as a boolean option. `"json"` is also supported. |
60
- | --exec | String | false | Execute a command after each episode is downloaded. See "Template Options" for more details. |
61
- | --parser-config | String | false | Path to JSON file that will be parsed and used to override the default config passed to [rss-parser](https://github.com/rbren/rss-parser#xml-options). |
62
- | --user-agent | String | false | Specify custom user agent string for HTTP requests. Defaults to a Chrome user agent if not specified. |
63
- | --proxy | | false | Enable proxy support. Specify environment variables listed by [global-agent](https://github.com/gajus/global-agent#environment-variables). |
64
- | --help | | false | Output usage information. |
65
-
66
- ## Archive
67
-
68
- - If passed the `--archive [path]` option, `podcast-dl` will generate/use a JSON archive at the provided path.
69
- - Before downloading an episode or writing out metadata, it'll check if the item was saved previously and abort the save if found.
70
-
71
- ## Template Options
72
-
73
- Options that support templates allow users to specify a template for the generated filename(s) or option. The provided template will replace all matched keywords with the related data described below. Each keyword must be wrapped in two braces like so:
74
-
75
- `--out-dir "./{{podcast_title}}"`
76
-
77
- `--episode-template "{{release_date}}-{{title}}"`
78
-
79
- ### `--out-dir` & `--archive`
80
-
81
- - `podcast_title`: Title of the podcast feed.
82
- - `podcast_link`: `link` value provided for the podcast feed. Typically the homepage URL.
83
-
84
- ### `--episode-template`
85
-
86
- - `title`: The title of the episode.
87
- - `release_date`: The release date of the episode in `YYYYMMDD` format.
88
- - `release_year`: The release year (`YYYY`) of the episode.
89
- - `release_month`: The release month (`MM`) of the episode.
90
- - `release_day`: The release day (`DD`) of the episode.
91
- - `episode_num`: The position number of where the episode appears in the feed.
92
- - `url`: URL of episode audio file.
93
- - `duration`: Provided `mm:ss` duration (if found).
94
- - `podcast_title`: Title of the podcast feed.
95
- - `podcast_link`: `link` value provided for the podcast feed. Typically the homepage URL.
96
- - `guid`: The GUID of the episode.
97
-
98
- #### `--episode-custom-template-options`
99
-
100
- Each matcher provided will be used to extract a value from the episode `title`. Access these values in the template using the `custom_<n>` keyword where `<n>` is the index of the matcher provided (starting from `0`).
101
-
102
- If no match is found, the `custom_<n>` keyword will be replaced with an empty string.
103
-
104
- ### `--exec`
105
-
106
- - `episode_path`: The path to the downloaded episode.
107
- - `episode_path_base`: The path to the folder of the downloaded episode.
108
- - `episode_filename`: The filename of the episode.
109
- - `episode_filename_base`: The filename of the episode without its extension.
110
- - `url`: URL of episode audio file.
111
-
112
- ### Template Filters
113
-
114
- Template variables can be transformed using filters. Filters are applied using the pipe (`|`) character and can be chained:
115
-
116
- `--episode-template "{{podcast_title|underscore}}-{{title|strip_special|camelcase}}"`
117
-
118
- For example, given `title` = "Serial- S01 E01: The Alibi":
119
-
120
- - `{{title|strip_special|underscore}}` produces `Serial S01 E01 The Alibi` then `Serial_S01_E01_The_Alibi`
121
- - `{{title|strip_special|camelcase}}` produces `SerialS01E01TheAlibi`
122
-
123
- #### Available Filters
124
-
125
- | Filter | Description | Input | Output |
126
- | --------------- | --------------------------------------------- | ------------- | ----------- |
127
- | `strip` | Remove all whitespace | `"foo bar"` | `"foobar"` |
128
- | `strip_special` | Remove non-alphanumeric chars (except spaces) | `"S01: E01!"` | `"S01 E01"` |
129
- | `underscore` | Replace whitespace with underscores | `"foo bar"` | `"foo_bar"` |
130
- | `dash` | Replace whitespace with dashes | `"foo bar"` | `"foo-bar"` |
131
- | `camelcase` | Convert to UpperCamelCase | `"foo bar"` | `"FooBar"` |
132
- | `lowercase` | Convert to lowercase | `"FOO Bar"` | `"foo bar"` |
133
- | `uppercase` | Convert to UPPERCASE | `"foo bar"` | `"FOO BAR"` |
134
- | `trim` | Remove leading/trailing whitespace | `" foo "` | `"foo"` |
135
-
136
- ## Log Levels
137
-
138
- By default, all logs and errors are outputted to the console. The amount of logs can be controlled using the environment variable `LOG_LEVEL` with the following options:
139
-
140
- - `static`: All logs and errors are outputted to the console, but disables any animations.
141
- - `quiet`: Only important info and non-critical errors will be logged (e.g. episode download started).
142
- - `silent`: Only critical error messages will be logged.
143
-
144
- ## OS Filename Limits
145
-
146
- By default, the max length of a generated filename is `255`. If your OS has different limitations, or if you're running into issues with non-standard feeds, you can adjust the limit via the environment variable `MAX_LENGTH_FILENAME`.
1
+ # podcast-dl
2
+
3
+ A humble CLI for downloading and archiving podcasts.
4
+
5
+ ## How to Use
6
+
7
+ ### npx
8
+
9
+ **[Node Required](https://nodejs.org/en/)**
10
+
11
+ `npx podcast-dl --url <PODCAST_RSS_URL>`
12
+
13
+ ### Binaries
14
+
15
+ [Visit the releases page](https://github.com/lightpohl/podcast-dl/releases) and download the latest binary for your system.
16
+
17
+ `podcast-dl --url <PODCAST_RSS_URL>`
18
+
19
+ ### [More Examples](./docs/examples.md)
20
+
21
+ ## Options
22
+
23
+ Either `--url` or `--file` must be provided.
24
+
25
+ Type values surrounded in square brackets (`[]`) can be used as boolean options (no argument required).
26
+
27
+ | Option | Type | Required | Description |
28
+ | --------------------------------- | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | --url | String | true\* | URL to podcast RSS feed. |
30
+ | --file | String | true\* | Path to local RSS file. |
31
+ | --out-dir | String | false | Specify output directory for episodes and metadata. Defaults to `"./{{podcast_title}}"`. See "Template Options" for more details. |
32
+ | --threads | Number | false | Determines the number of downloads that will happen concurrently. Default is `1`. |
33
+ | --attempts | Number | false | Sets the number of download attempts per individual file. Default is `3`. |
34
+ | --archive | [String] | false | Download or write out items not listed in archive file. Generates archive file at path if not found. Defaults to `"./{{podcast_title}}/archive.json"` when used as a boolean option. See "Template Options" for more details. |
35
+ | --episode-template | String | false | Template for generating episode related filenames. See "Template Options" for details. |
36
+ | --episode-custom-template-options | <String...> | false | Provide custom options for the episode template. See "Template Options" for details. |
37
+ | --include-meta | | false | Write out podcast metadata to JSON. |
38
+ | --include-episode-meta | | false | Write out individual episode metadata to JSON. |
39
+ | --include-episode-images | | false | Download found episode images. |
40
+ | --include-episode-transcripts | | false | Download found episode transcripts. |
41
+ | --offset | Number | false | Offset starting download position. Default is `0`. |
42
+ | --limit | Number | false | Max number of episodes to download. Downloads all by default. |
43
+ | --after | String | false | Only download episodes after this date (i.e. MM/DD/YYYY, inclusive). |
44
+ | --before | String | false | Only download episodes before this date (i.e. MM/DD/YYYY, inclusive). |
45
+ | --episode-regex | String | false | Match episode title against provided regex before starting download. |
46
+ | --episode-regex-exclude | String | false | Episode titles matching provided regex will be excluded. |
47
+ | --episode-digits | Number | false | Minimum number of digits to use for episode numbering (e.g. 3 would generate "001" instead of "1"). Default is `1`. |
48
+ | --episode-num-offset | Number | false | Offset the acquired episode number. Default is `0`. |
49
+ | --episode-source-order | String | false | Attempted order to extract episode audio URL from RSS feed. Default is `"enclosure,link"`. |
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
+ | --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
+ | --add-mp3-metadata | | false | Attempts to add a base level of episode metadata to each episode. Recommended only in cases where the original metadata is of poor quality. (**ffmpeg required**) |
53
+ | --adjust-bitrate | String (e.g. "48k") | false | Attempts to adjust bitrate of episodes. (**ffmpeg required**) |
54
+ | --mono | | false | Attempts to force episodes into mono. (**ffmpeg required**) |
55
+ | --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 `--add-mp3-metadata`, `--adjust-bitrate`, `--mono`, and `--exec`. |
57
+ | --reverse | | false | Reverse download direction and start at last RSS item. |
58
+ | --info | | false | Print retrieved podcast info instead of downloading. |
59
+ | --list | [String] | false | Print episode list instead of downloading. Defaults to `"table"` when used as a boolean option. `"json"` is also supported. |
60
+ | --exec | String | false | Execute a command after each episode is downloaded. See "Template Options" for more details. |
61
+ | --parser-config | String | false | Path to JSON file that will be parsed and used to override the default config passed to [rss-parser](https://github.com/rbren/rss-parser#xml-options). |
62
+ | --user-agent | String | false | Specify custom user agent string for HTTP requests. Defaults to a Chrome user agent if not specified. |
63
+ | --proxy | | false | Enable proxy support. Specify environment variables listed by [global-agent](https://github.com/gajus/global-agent#environment-variables). |
64
+ | --trust-ext | | false | Trust file extension from URL, skip MIME-based correction. |
65
+ | --help | | false | Output usage information. |
66
+
67
+ ## Archive
68
+
69
+ - If passed the `--archive [path]` option, `podcast-dl` will generate/use a JSON archive at the provided path.
70
+ - Before downloading an episode or writing out metadata, it'll check if the item was saved previously and abort the save if found.
71
+
72
+ ## Template Options
73
+
74
+ Options that support templates allow users to specify a template for the generated filename(s) or option. The provided template will replace all matched keywords with the related data described below. Each keyword must be wrapped in two braces like so:
75
+
76
+ `--out-dir "./{{podcast_title}}"`
77
+
78
+ `--episode-template "{{release_date}}-{{title}}"`
79
+
80
+ ### `--out-dir` & `--archive`
81
+
82
+ - `podcast_title`: Title of the podcast feed.
83
+ - `podcast_link`: `link` value provided for the podcast feed. Typically the homepage URL.
84
+
85
+ ### `--episode-template`
86
+
87
+ - `title`: The title of the episode.
88
+ - `release_date`: The release date of the episode in `YYYYMMDD` format.
89
+ - `release_year`: The release year (`YYYY`) of the episode.
90
+ - `release_month`: The release month (`MM`) of the episode.
91
+ - `release_day`: The release day (`DD`) of the episode.
92
+ - `episode_num`: The position number of where the episode appears in the feed.
93
+ - `url`: URL of episode audio file.
94
+ - `duration`: Provided `mm:ss` duration (if found).
95
+ - `podcast_title`: Title of the podcast feed.
96
+ - `podcast_link`: `link` value provided for the podcast feed. Typically the homepage URL.
97
+ - `guid`: The GUID of the episode.
98
+
99
+ #### `--episode-custom-template-options`
100
+
101
+ Each matcher provided will be used to extract a value from the episode `title`. Access these values in the template using the `custom_<n>` keyword where `<n>` is the index of the matcher provided (starting from `0`).
102
+
103
+ If no match is found, the `custom_<n>` keyword will be replaced with an empty string.
104
+
105
+ ### `--exec`
106
+
107
+ - `episode_path`: The path to the downloaded episode.
108
+ - `episode_path_base`: The path to the folder of the downloaded episode.
109
+ - `episode_filename`: The filename of the episode.
110
+ - `episode_filename_base`: The filename of the episode without its extension.
111
+ - `url`: URL of episode audio file.
112
+
113
+ ### Template Filters
114
+
115
+ Template variables can be transformed using filters. Filters are applied using the pipe (`|`) character and can be chained:
116
+
117
+ `--episode-template "{{podcast_title|underscore}}-{{title|strip_special|camelcase}}"`
118
+
119
+ For example, given `title` = "Serial- S01 E01: The Alibi":
120
+
121
+ - `{{title|strip_special|underscore}}` produces `Serial S01 E01 The Alibi` then `Serial_S01_E01_The_Alibi`
122
+ - `{{title|strip_special|camelcase}}` produces `SerialS01E01TheAlibi`
123
+
124
+ #### Available Filters
125
+
126
+ | Filter | Description | Input | Output |
127
+ | --------------- | --------------------------------------------- | ------------- | ----------- |
128
+ | `strip` | Remove all whitespace | `"foo bar"` | `"foobar"` |
129
+ | `strip_special` | Remove non-alphanumeric chars (except spaces) | `"S01: E01!"` | `"S01 E01"` |
130
+ | `underscore` | Replace whitespace with underscores | `"foo bar"` | `"foo_bar"` |
131
+ | `dash` | Replace whitespace with dashes | `"foo bar"` | `"foo-bar"` |
132
+ | `camelcase` | Convert to UpperCamelCase | `"foo bar"` | `"FooBar"` |
133
+ | `lowercase` | Convert to lowercase | `"FOO Bar"` | `"foo bar"` |
134
+ | `uppercase` | Convert to UPPERCASE | `"foo bar"` | `"FOO BAR"` |
135
+ | `trim` | Remove leading/trailing whitespace | `" foo "` | `"foo"` |
136
+
137
+ ## Log Levels
138
+
139
+ By default, all logs and errors are outputted to the console. The amount of logs can be controlled using the environment variable `LOG_LEVEL` with the following options:
140
+
141
+ - `static`: All logs and errors are outputted to the console, but disables any animations.
142
+ - `quiet`: Only important info and non-critical errors will be logged (e.g. episode download started).
143
+ - `silent`: Only critical error messages will be logged.
144
+
145
+ ## OS Filename Limits
146
+
147
+ By default, the max length of a generated filename is `255`. If your OS has different limitations, or if you're running into issues with non-standard feeds, you can adjust the limit via the environment variable `MAX_LENGTH_FILENAME`.
package/bin/async.js CHANGED
@@ -46,6 +46,7 @@ export const download = async (options) => {
46
46
  onAfterDownload,
47
47
  attempt = 1,
48
48
  maxAttempts = 3,
49
+ trustExt,
49
50
  userAgent = USER_AGENT,
50
51
  } = options;
51
52
 
@@ -76,7 +77,7 @@ export const download = async (options) => {
76
77
  "user-agent": userAgent,
77
78
  },
78
79
  });
79
- } catch (error) {
80
+ } catch {
80
81
  // unable to retrieve head response
81
82
  }
82
83
 
@@ -148,17 +149,18 @@ export const download = async (options) => {
148
149
  return null;
149
150
  }
150
151
 
151
- const { outputPath: finalOutputPath, key: finalKey } =
152
- correctExtensionFromMime({
153
- outputPath,
154
- key,
155
- contentType: headResponse?.headers?.["content-type"],
156
- onCorrect: (from, to) =>
157
- logMessage(
158
- `Correcting extension: ${from} --> ${to}`,
159
- LOG_LEVELS.important
160
- ),
161
- });
152
+ const { outputPath: finalOutputPath, key: finalKey } = trustExt
153
+ ? { outputPath, key }
154
+ : correctExtensionFromMime({
155
+ outputPath,
156
+ key,
157
+ contentType: headResponse?.headers?.["content-type"],
158
+ onCorrect: (from, to) =>
159
+ logMessage(
160
+ `Correcting extension: ${from} --> ${to}`,
161
+ LOG_LEVELS.important
162
+ ),
163
+ });
162
164
 
163
165
  fs.renameSync(tempOutputPath, finalOutputPath);
164
166
 
@@ -200,6 +202,7 @@ export const downloadItemsAsync = async ({
200
202
  alwaysPostprocess,
201
203
  targetItems,
202
204
  threads = 1,
205
+ trustExt,
203
206
  userAgent = USER_AGENT,
204
207
  }) => {
205
208
  let numEpisodesDownloaded = 0;
@@ -239,6 +242,7 @@ export const downloadItemsAsync = async ({
239
242
  override,
240
243
  alwaysPostprocess,
241
244
  marker,
245
+ trustExt,
242
246
  userAgent,
243
247
  key: getArchiveKey({
244
248
  prefix: archivePrefix,
@@ -257,6 +261,7 @@ export const downloadItemsAsync = async ({
257
261
  const finalImagePath = await download({
258
262
  archive,
259
263
  override,
264
+ trustExt,
260
265
  userAgent,
261
266
  key: item._episodeImage.key,
262
267
  marker: item._episodeImage.url,
@@ -287,6 +292,7 @@ export const downloadItemsAsync = async ({
287
292
  marker: item._episodeTranscript.url,
288
293
  maxAttempts: attempts,
289
294
  outputPath: item._episodeTranscript.outputPath,
295
+ trustExt,
290
296
  url: item._episodeTranscript.url,
291
297
  userAgent,
292
298
  });
package/bin/bin.js CHANGED
@@ -63,6 +63,7 @@ const {
63
63
  addMp3Metadata: addMp3MetadataFlag,
64
64
  adjustBitrate: bitrate,
65
65
  season,
66
+ trustExt,
66
67
  } = opts;
67
68
 
68
69
  let { archive } = opts;
@@ -156,6 +157,7 @@ const main = async () => {
156
157
  await download({
157
158
  archive,
158
159
  override,
160
+ trustExt,
159
161
  userAgent,
160
162
  marker: podcastImageUrl,
161
163
  key: getArchiveKey({
@@ -257,6 +259,7 @@ const main = async () => {
257
259
  alwaysPostprocess,
258
260
  targetItems,
259
261
  threads,
262
+ trustExt,
260
263
  userAgent,
261
264
  });
262
265
 
package/bin/commander.js CHANGED
@@ -1,199 +1,200 @@
1
- import { ITEM_LIST_FORMATS } from "./items.js";
2
- import { logErrorAndExit } from "./logger.js";
3
- import { AUDIO_ORDER_TYPES, TRANSCRIPT_TYPES } from "./util.js";
4
- import { createParseNumber, hasFfmpeg } from "./validate.js";
5
-
6
- export const setupCommander = (program) => {
7
- program
8
- .option("--url <string>", "url to podcast rss feed")
9
- .option("--file <path>", "local path to podcast rss feed")
10
- .option(
11
- "--out-dir <path>",
12
- "specify output directory",
13
- "./{{podcast_title}}"
14
- )
15
- .option(
16
- "--archive [path]",
17
- "download or write only items not listed in archive file"
18
- )
19
- .option(
20
- "--episode-template <string>",
21
- "template for generating episode related filenames",
22
- "{{release_date}}-{{title}}"
23
- )
24
- .option(
25
- "--episode-custom-template-options <patterns...>",
26
- "create custom options for the episode template"
27
- )
28
- .option(
29
- "--episode-digits <number>",
30
- "minimum number of digits to use for episode numbering (leading zeros)",
31
- createParseNumber({ min: 0, name: "--episode-digits" }),
32
- 1
33
- )
34
- .option(
35
- "--episode-num-offset <number>",
36
- "offset the acquired episode number",
37
- createParseNumber({
38
- min: Number.MIN_SAFE_INTEGER,
39
- max: Number.MAX_SAFE_INTEGER,
40
- name: "--episode-num-offset",
41
- }),
42
- 0
43
- )
44
- .option(
45
- "--episode-source-order <string>",
46
- "attempted order to extract episode audio URL from rss feed",
47
- (value) => {
48
- const parsed = value.split(",").map((type) => type.trim());
49
- const isValid = parsed.every((type) => !!AUDIO_ORDER_TYPES[type]);
50
-
51
- if (!isValid) {
52
- logErrorAndExit(
53
- `Invalid type found in --episode-source-order: ${value}\n`
54
- );
55
- }
56
-
57
- return parsed;
58
- },
59
- [AUDIO_ORDER_TYPES.enclosure, AUDIO_ORDER_TYPES.link]
60
- )
61
- .option("--include-meta", "write out podcast metadata to json")
62
- .option(
63
- "--include-episode-meta",
64
- "write out individual episode metadata to json"
65
- )
66
- .option(
67
- "--include-episode-transcripts",
68
- "download found episode transcripts"
69
- )
70
- .option(
71
- "--episode-transcript-types <string>",
72
- "list of allowed transcript types in preferred order",
73
- (value) => {
74
- const parsed = value.split(",").map((type) => type.trim());
75
- const isValid = parsed.every((type) => !!TRANSCRIPT_TYPES[type]);
76
-
77
- if (!isValid) {
78
- logErrorAndExit(
79
- `Invalid type found in --transcript-types: ${value}\n`
80
- );
81
- }
82
-
83
- return parsed;
84
- },
85
- [
86
- TRANSCRIPT_TYPES["application/json"],
87
- TRANSCRIPT_TYPES["application/x-subrip"],
88
- TRANSCRIPT_TYPES["application/srr"],
89
- TRANSCRIPT_TYPES["application/srt"],
90
- TRANSCRIPT_TYPES["text/vtt"],
91
- TRANSCRIPT_TYPES["text/html"],
92
- TRANSCRIPT_TYPES["text/plain"],
93
- ]
94
- )
95
- .option("--include-episode-images", "download found episode images")
96
- .option(
97
- "--offset <number>",
98
- "offset episode to start downloading from (most recent = 0)",
99
- createParseNumber({ min: 0, name: "--offset" }),
100
- 0
101
- )
102
- .option(
103
- "--limit <number>",
104
- "max amount of episodes to download",
105
- createParseNumber({ min: 1, name: "--limit", require: false })
106
- )
107
- .option(
108
- "--episode-regex <string>",
109
- "match episode title against regex before downloading"
110
- )
111
- .option(
112
- "--episode-regex-exclude <string>",
113
- "episode titles matching regex will be excluded"
114
- )
115
- .option(
116
- "--season <number>",
117
- "download only episodes from this season",
118
- createParseNumber({ min: 0, name: "--season" })
119
- )
120
- .option(
121
- "--after <string>",
122
- "download episodes only after this date (inclusive)"
123
- )
124
- .option(
125
- "--before <string>",
126
- "download episodes only before this date (inclusive)"
127
- )
128
- .option(
129
- "--add-mp3-metadata",
130
- "attempts to add a base level of metadata to episode files using ffmpeg",
131
- hasFfmpeg
132
- )
133
- .option(
134
- "--adjust-bitrate <string>",
135
- "attempts to adjust bitrate of episode files using ffmpeg",
136
- hasFfmpeg
137
- )
138
- .option(
139
- "--mono",
140
- "attempts to force episode files into mono using ffmpeg",
141
- hasFfmpeg
142
- )
143
- .option("--override", "override local files on collision")
144
- .option(
145
- "--always-postprocess",
146
- "always run additional tasks on the file regardless of whether the file already exists"
147
- )
148
- .option("--reverse", "download episodes in reverse order")
149
- .option("--info", "print retrieved podcast info instead of downloading")
150
- .option(
151
- "--list [table|json]",
152
- "print episode info instead of downloading",
153
- (value) => {
154
- if (!ITEM_LIST_FORMATS.includes(value)) {
155
- logErrorAndExit(
156
- `${value} is an invalid format for --list\nUse one of the following: ${ITEM_LIST_FORMATS.join(
157
- ", "
158
- )}`
159
- );
160
- }
161
-
162
- return value;
163
- }
164
- )
165
- .option(
166
- "--exec <string>",
167
- "execute a command after each episode is downloaded"
168
- )
169
- .option(
170
- "--threads <number>",
171
- "the number of downloads that can happen concurrently",
172
- createParseNumber({
173
- min: 1,
174
- max: Number.MAX_SAFE_INTEGER,
175
- name: "threads",
176
- }),
177
- 1
178
- )
179
- .option(
180
- "--attempts <number>",
181
- "the number of attempts for an individual download",
182
- createParseNumber({
183
- min: 1,
184
- max: Number.MAX_SAFE_INTEGER,
185
- name: "attempts",
186
- }),
187
- 3
188
- )
189
- .option(
190
- "--parser-config <string>",
191
- "path to JSON config to override RSS parser"
192
- )
193
- .option("--proxy", "enable proxy support via global-agent")
194
- .option("--user-agent <string>", "specify custom user agent string");
195
-
196
- program.parse();
197
-
198
- return program.opts();
199
- };
1
+ import { ITEM_LIST_FORMATS } from "./items.js";
2
+ import { logErrorAndExit } from "./logger.js";
3
+ import { AUDIO_ORDER_TYPES, TRANSCRIPT_TYPES } from "./util.js";
4
+ import { createParseNumber, hasFfmpeg } from "./validate.js";
5
+
6
+ export const setupCommander = (program) => {
7
+ program
8
+ .option("--url <string>", "url to podcast rss feed")
9
+ .option("--file <path>", "local path to podcast rss feed")
10
+ .option(
11
+ "--out-dir <path>",
12
+ "specify output directory",
13
+ "./{{podcast_title}}"
14
+ )
15
+ .option(
16
+ "--archive [path]",
17
+ "download or write only items not listed in archive file"
18
+ )
19
+ .option(
20
+ "--episode-template <string>",
21
+ "template for generating episode related filenames",
22
+ "{{release_date}}-{{title}}"
23
+ )
24
+ .option(
25
+ "--episode-custom-template-options <patterns...>",
26
+ "create custom options for the episode template"
27
+ )
28
+ .option(
29
+ "--episode-digits <number>",
30
+ "minimum number of digits to use for episode numbering (leading zeros)",
31
+ createParseNumber({ min: 0, name: "--episode-digits" }),
32
+ 1
33
+ )
34
+ .option(
35
+ "--episode-num-offset <number>",
36
+ "offset the acquired episode number",
37
+ createParseNumber({
38
+ min: Number.MIN_SAFE_INTEGER,
39
+ max: Number.MAX_SAFE_INTEGER,
40
+ name: "--episode-num-offset",
41
+ }),
42
+ 0
43
+ )
44
+ .option(
45
+ "--episode-source-order <string>",
46
+ "attempted order to extract episode audio URL from rss feed",
47
+ (value) => {
48
+ const parsed = value.split(",").map((type) => type.trim());
49
+ const isValid = parsed.every((type) => !!AUDIO_ORDER_TYPES[type]);
50
+
51
+ if (!isValid) {
52
+ logErrorAndExit(
53
+ `Invalid type found in --episode-source-order: ${value}\n`
54
+ );
55
+ }
56
+
57
+ return parsed;
58
+ },
59
+ [AUDIO_ORDER_TYPES.enclosure, AUDIO_ORDER_TYPES.link]
60
+ )
61
+ .option("--include-meta", "write out podcast metadata to json")
62
+ .option(
63
+ "--include-episode-meta",
64
+ "write out individual episode metadata to json"
65
+ )
66
+ .option(
67
+ "--include-episode-transcripts",
68
+ "download found episode transcripts"
69
+ )
70
+ .option(
71
+ "--episode-transcript-types <string>",
72
+ "list of allowed transcript types in preferred order",
73
+ (value) => {
74
+ const parsed = value.split(",").map((type) => type.trim());
75
+ const isValid = parsed.every((type) => !!TRANSCRIPT_TYPES[type]);
76
+
77
+ if (!isValid) {
78
+ logErrorAndExit(
79
+ `Invalid type found in --transcript-types: ${value}\n`
80
+ );
81
+ }
82
+
83
+ return parsed;
84
+ },
85
+ [
86
+ TRANSCRIPT_TYPES["application/json"],
87
+ TRANSCRIPT_TYPES["application/x-subrip"],
88
+ TRANSCRIPT_TYPES["application/srr"],
89
+ TRANSCRIPT_TYPES["application/srt"],
90
+ TRANSCRIPT_TYPES["text/vtt"],
91
+ TRANSCRIPT_TYPES["text/html"],
92
+ TRANSCRIPT_TYPES["text/plain"],
93
+ ]
94
+ )
95
+ .option("--include-episode-images", "download found episode images")
96
+ .option(
97
+ "--offset <number>",
98
+ "offset episode to start downloading from (most recent = 0)",
99
+ createParseNumber({ min: 0, name: "--offset" }),
100
+ 0
101
+ )
102
+ .option(
103
+ "--limit <number>",
104
+ "max amount of episodes to download",
105
+ createParseNumber({ min: 1, name: "--limit", require: false })
106
+ )
107
+ .option(
108
+ "--episode-regex <string>",
109
+ "match episode title against regex before downloading"
110
+ )
111
+ .option(
112
+ "--episode-regex-exclude <string>",
113
+ "episode titles matching regex will be excluded"
114
+ )
115
+ .option(
116
+ "--season <number>",
117
+ "download only episodes from this season",
118
+ createParseNumber({ min: 0, name: "--season" })
119
+ )
120
+ .option(
121
+ "--after <string>",
122
+ "download episodes only after this date (inclusive)"
123
+ )
124
+ .option(
125
+ "--before <string>",
126
+ "download episodes only before this date (inclusive)"
127
+ )
128
+ .option(
129
+ "--add-mp3-metadata",
130
+ "attempts to add a base level of metadata to episode files using ffmpeg",
131
+ hasFfmpeg
132
+ )
133
+ .option(
134
+ "--adjust-bitrate <string>",
135
+ "attempts to adjust bitrate of episode files using ffmpeg",
136
+ hasFfmpeg
137
+ )
138
+ .option(
139
+ "--mono",
140
+ "attempts to force episode files into mono using ffmpeg",
141
+ hasFfmpeg
142
+ )
143
+ .option("--override", "override local files on collision")
144
+ .option(
145
+ "--always-postprocess",
146
+ "always run additional tasks on the file regardless of whether the file already exists"
147
+ )
148
+ .option("--reverse", "download episodes in reverse order")
149
+ .option("--info", "print retrieved podcast info instead of downloading")
150
+ .option(
151
+ "--list [table|json]",
152
+ "print episode info instead of downloading",
153
+ (value) => {
154
+ if (!ITEM_LIST_FORMATS.includes(value)) {
155
+ logErrorAndExit(
156
+ `${value} is an invalid format for --list\nUse one of the following: ${ITEM_LIST_FORMATS.join(
157
+ ", "
158
+ )}`
159
+ );
160
+ }
161
+
162
+ return value;
163
+ }
164
+ )
165
+ .option(
166
+ "--exec <string>",
167
+ "execute a command after each episode is downloaded"
168
+ )
169
+ .option(
170
+ "--threads <number>",
171
+ "the number of downloads that can happen concurrently",
172
+ createParseNumber({
173
+ min: 1,
174
+ max: Number.MAX_SAFE_INTEGER,
175
+ name: "threads",
176
+ }),
177
+ 1
178
+ )
179
+ .option(
180
+ "--attempts <number>",
181
+ "the number of attempts for an individual download",
182
+ createParseNumber({
183
+ min: 1,
184
+ max: Number.MAX_SAFE_INTEGER,
185
+ name: "attempts",
186
+ }),
187
+ 3
188
+ )
189
+ .option(
190
+ "--parser-config <string>",
191
+ "path to JSON config to override RSS parser"
192
+ )
193
+ .option("--proxy", "enable proxy support via global-agent")
194
+ .option("--user-agent <string>", "specify custom user agent string")
195
+ .option("--trust-ext", "trust file extension, skip MIME-based correction");
196
+
197
+ program.parse();
198
+
199
+ return program.opts();
200
+ };
package/bin/util.js CHANGED
@@ -190,6 +190,49 @@ export const MIME_TO_EXT = {
190
190
 
191
191
  export const getExtFromMime = (mime) => MIME_TO_EXT[mime] || null;
192
192
 
193
+ const MEDIA_CATEGORIES = {
194
+ audio: "audio",
195
+ image: "image",
196
+ transcript: "transcript",
197
+ };
198
+
199
+ const VALID_AUDIO_EXTS_SET = new Set(Object.values(AUDIO_TYPES_TO_EXTS));
200
+ const VALID_IMAGE_EXTS_SET = new Set(Object.values(IMAGE_TYPES_TO_EXTS));
201
+ const VALID_TRANSCRIPT_EXTS_SET = new Set(
202
+ Object.values(TRANSCRIPT_TYPES_TO_EXTS)
203
+ );
204
+
205
+ const getExtCategory = (ext) => {
206
+ if (VALID_AUDIO_EXTS_SET.has(ext)) {
207
+ return MEDIA_CATEGORIES.audio;
208
+ }
209
+
210
+ if (VALID_IMAGE_EXTS_SET.has(ext)) {
211
+ return MEDIA_CATEGORIES.image;
212
+ }
213
+
214
+ if (VALID_TRANSCRIPT_EXTS_SET.has(ext)) {
215
+ return MEDIA_CATEGORIES.transcript;
216
+ }
217
+ return null;
218
+ };
219
+
220
+ const getMimeCategory = (mime) => {
221
+ if (AUDIO_TYPES_TO_EXTS[mime]) {
222
+ return MEDIA_CATEGORIES.audio;
223
+ }
224
+
225
+ if (IMAGE_TYPES_TO_EXTS[mime]) {
226
+ return MEDIA_CATEGORIES.image;
227
+ }
228
+
229
+ if (TRANSCRIPT_TYPES_TO_EXTS[mime]) {
230
+ return MEDIA_CATEGORIES.transcript;
231
+ }
232
+
233
+ return null;
234
+ };
235
+
193
236
  export const correctExtensionFromMime = ({
194
237
  outputPath,
195
238
  key,
@@ -208,6 +251,13 @@ export const correctExtensionFromMime = ({
208
251
  return { outputPath, key };
209
252
  }
210
253
 
254
+ const currentCategory = getExtCategory(currentExt);
255
+ const mimeCategory = getMimeCategory(mimeType);
256
+
257
+ if (currentCategory && mimeCategory && currentCategory !== mimeCategory) {
258
+ return { outputPath, key };
259
+ }
260
+
211
261
  const basePath = currentExt
212
262
  ? outputPath.slice(0, -currentExt.length)
213
263
  : outputPath;
@@ -229,7 +279,7 @@ export const getIsAudioUrl = (url) => {
229
279
  let ext;
230
280
  try {
231
281
  ext = getUrlExt(url);
232
- } catch (err) {
282
+ } catch {
233
283
  return false;
234
284
  }
235
285
 
package/bin/validate.js CHANGED
@@ -1,39 +1,39 @@
1
- import { sync as commandExistsSync } from "command-exists";
2
- import { logErrorAndExit } from "./logger.js";
3
-
4
- export const createParseNumber = ({ min, max, name, required = true }) => {
5
- return (value) => {
6
- if (!value && !required) {
7
- return undefined;
8
- }
9
-
10
- try {
11
- let number = parseInt(value);
12
- if (isNaN(number)) {
13
- logErrorAndExit(`${name} must be a number`);
14
- }
15
-
16
- if (typeof min !== undefined && number < min) {
17
- logErrorAndExit(`${name} must be >= ${min}`);
18
- }
19
-
20
- if (typeof max !== undefined && number > max) {
21
- logErrorAndExit(
22
- `${name} must be <= ${
23
- max === Number.MAX_SAFE_INTEGER ? "Number.MAX_SAFE_INTEGER" : max
24
- }`
25
- );
26
- }
27
-
28
- return number;
29
- } catch (error) {
30
- logErrorAndExit(`Unable to parse ${name}`);
31
- }
32
- };
33
- };
34
-
35
- export const hasFfmpeg = () => {
36
- if (!commandExistsSync("ffmpeg")) {
37
- logErrorAndExit('option specified requires "ffmpeg" be available');
38
- }
39
- };
1
+ import { sync as commandExistsSync } from "command-exists";
2
+ import { logErrorAndExit } from "./logger.js";
3
+
4
+ export const createParseNumber = ({ min, max, name, required = true }) => {
5
+ return (value) => {
6
+ if (!value && !required) {
7
+ return undefined;
8
+ }
9
+
10
+ try {
11
+ let number = parseInt(value);
12
+ if (isNaN(number)) {
13
+ logErrorAndExit(`${name} must be a number`);
14
+ }
15
+
16
+ if (min !== undefined && number < min) {
17
+ logErrorAndExit(`${name} must be >= ${min}`);
18
+ }
19
+
20
+ if (max !== undefined && number > max) {
21
+ logErrorAndExit(
22
+ `${name} must be <= ${
23
+ max === Number.MAX_SAFE_INTEGER ? "Number.MAX_SAFE_INTEGER" : max
24
+ }`
25
+ );
26
+ }
27
+
28
+ return number;
29
+ } catch {
30
+ logErrorAndExit(`Unable to parse ${name}`);
31
+ }
32
+ };
33
+ };
34
+
35
+ export const hasFfmpeg = () => {
36
+ if (!commandExistsSync("ffmpeg")) {
37
+ logErrorAndExit('option specified requires "ffmpeg" be available');
38
+ }
39
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "podcast-dl",
3
- "version": "11.4.1",
3
+ "version": "11.5.0",
4
4
  "description": "A CLI for downloading podcasts.",
5
5
  "type": "module",
6
6
  "bin": "./bin/bin.js",
@@ -38,9 +38,10 @@
38
38
  "author": "Joshua Pohl",
39
39
  "license": "MIT",
40
40
  "devDependencies": {
41
+ "@eslint/js": "^9.18.0",
41
42
  "@yao-pkg/pkg": "^6.6.0",
42
- "eslint": "^6.8.0",
43
- "eslint-config-prettier": "^6.11.0",
43
+ "eslint": "^9.18.0",
44
+ "globals": "^15.14.0",
44
45
  "husky": "^4.2.5",
45
46
  "lint-staged": "^10.1.7",
46
47
  "prettier": "2.3.2",