podcast-dl 11.1.1 → 11.2.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/LICENSE +21 -21
- package/README.md +121 -120
- package/bin/archive.js +39 -39
- package/bin/async.js +370 -370
- package/bin/bin.js +292 -289
- package/bin/commander.js +198 -193
- package/bin/exec.js +30 -30
- package/bin/ffmpeg.js +105 -105
- package/bin/items.js +247 -237
- package/bin/logger.js +84 -84
- package/bin/meta.js +66 -66
- package/bin/naming.js +112 -112
- package/bin/util.js +299 -299
- package/bin/validate.js +39 -39
- package/package.json +62 -62
package/bin/commander.js
CHANGED
|
@@ -1,193 +1,198 @@
|
|
|
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
|
-
"matched episode titles against regex will be excluded"
|
|
114
|
-
)
|
|
115
|
-
.option(
|
|
116
|
-
"--
|
|
117
|
-
"download episodes
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
.option(
|
|
129
|
-
"--
|
|
130
|
-
"attempts to
|
|
131
|
-
hasFfmpeg
|
|
132
|
-
)
|
|
133
|
-
.option(
|
|
134
|
-
"--
|
|
135
|
-
"attempts to
|
|
136
|
-
hasFfmpeg
|
|
137
|
-
)
|
|
138
|
-
.option(
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
|
|
142
|
-
)
|
|
143
|
-
.option("--
|
|
144
|
-
.option(
|
|
145
|
-
|
|
146
|
-
"
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
"matched episode titles against 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
|
+
|
|
195
|
+
program.parse();
|
|
196
|
+
|
|
197
|
+
return program.opts();
|
|
198
|
+
};
|
package/bin/exec.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import { exec } from "child_process";
|
|
2
|
-
import util from "util";
|
|
3
|
-
import { escapeArgForShell } from "./util.js";
|
|
4
|
-
|
|
5
|
-
export const execWithPromise = util.promisify(exec);
|
|
6
|
-
|
|
7
|
-
export const runExec = async ({
|
|
8
|
-
exec,
|
|
9
|
-
basePath,
|
|
10
|
-
outputPodcastPath,
|
|
11
|
-
episodeFilename,
|
|
12
|
-
episodeAudioUrl,
|
|
13
|
-
}) => {
|
|
14
|
-
const episodeFilenameBase = episodeFilename.substring(
|
|
15
|
-
0,
|
|
16
|
-
episodeFilename.lastIndexOf(".")
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
const execCmd = exec
|
|
20
|
-
.replace(/{{episode_path}}/g, escapeArgForShell(outputPodcastPath))
|
|
21
|
-
.replace(/{{episode_path_base}}/g, escapeArgForShell(basePath))
|
|
22
|
-
.replace(/{{episode_filename}}/g, escapeArgForShell(episodeFilename))
|
|
23
|
-
.replace(
|
|
24
|
-
/{{episode_filename_base}}/g,
|
|
25
|
-
escapeArgForShell(episodeFilenameBase)
|
|
26
|
-
)
|
|
27
|
-
.replace(/{{url}}/g, escapeArgForShell(episodeAudioUrl));
|
|
28
|
-
|
|
29
|
-
await execWithPromise(execCmd, { stdio: "ignore" });
|
|
30
|
-
};
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import util from "util";
|
|
3
|
+
import { escapeArgForShell } from "./util.js";
|
|
4
|
+
|
|
5
|
+
export const execWithPromise = util.promisify(exec);
|
|
6
|
+
|
|
7
|
+
export const runExec = async ({
|
|
8
|
+
exec,
|
|
9
|
+
basePath,
|
|
10
|
+
outputPodcastPath,
|
|
11
|
+
episodeFilename,
|
|
12
|
+
episodeAudioUrl,
|
|
13
|
+
}) => {
|
|
14
|
+
const episodeFilenameBase = episodeFilename.substring(
|
|
15
|
+
0,
|
|
16
|
+
episodeFilename.lastIndexOf(".")
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const execCmd = exec
|
|
20
|
+
.replace(/{{episode_path}}/g, escapeArgForShell(outputPodcastPath))
|
|
21
|
+
.replace(/{{episode_path_base}}/g, escapeArgForShell(basePath))
|
|
22
|
+
.replace(/{{episode_filename}}/g, escapeArgForShell(episodeFilename))
|
|
23
|
+
.replace(
|
|
24
|
+
/{{episode_filename_base}}/g,
|
|
25
|
+
escapeArgForShell(episodeFilenameBase)
|
|
26
|
+
)
|
|
27
|
+
.replace(/{{url}}/g, escapeArgForShell(episodeAudioUrl));
|
|
28
|
+
|
|
29
|
+
await execWithPromise(execCmd, { stdio: "ignore" });
|
|
30
|
+
};
|
package/bin/ffmpeg.js
CHANGED
|
@@ -1,105 +1,105 @@
|
|
|
1
|
-
import dayjs from "dayjs";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import { execWithPromise } from "./exec.js";
|
|
4
|
-
import { LOG_LEVELS, logMessage } from "./logger.js";
|
|
5
|
-
import { escapeArgForShell, isWin } from "./util.js";
|
|
6
|
-
|
|
7
|
-
export const runFfmpeg = async ({
|
|
8
|
-
feed,
|
|
9
|
-
item,
|
|
10
|
-
itemIndex,
|
|
11
|
-
outputPath,
|
|
12
|
-
episodeImageOutputPath,
|
|
13
|
-
bitrate,
|
|
14
|
-
mono,
|
|
15
|
-
addMp3Metadata,
|
|
16
|
-
ext,
|
|
17
|
-
}) => {
|
|
18
|
-
if (!fs.existsSync(outputPath)) {
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const shouldEmbedImage = addMp3Metadata && episodeImageOutputPath;
|
|
23
|
-
let command = `ffmpeg -loglevel quiet -i ${escapeArgForShell(outputPath)}`;
|
|
24
|
-
|
|
25
|
-
if (shouldEmbedImage) {
|
|
26
|
-
command += ` -i ${escapeArgForShell(episodeImageOutputPath)}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (bitrate) {
|
|
30
|
-
command += ` -b:a ${bitrate}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (mono) {
|
|
34
|
-
command += " -ac 1";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (addMp3Metadata) {
|
|
38
|
-
const album = feed.title || "";
|
|
39
|
-
const artist = item.itunes?.author || item.author || "";
|
|
40
|
-
const title = item.title || "";
|
|
41
|
-
const subtitle = item.itunes?.subtitle || "";
|
|
42
|
-
const comment = item.contentSnippet || item.content || "";
|
|
43
|
-
const disc = item.itunes?.season || "";
|
|
44
|
-
const track = item.itunes?.episode || `${feed.items.length - itemIndex}`;
|
|
45
|
-
const episodeType = item.itunes?.episodeType || "";
|
|
46
|
-
const date = item.pubDate
|
|
47
|
-
? dayjs(new Date(item.pubDate)).format("YYYY-MM-DD")
|
|
48
|
-
: "";
|
|
49
|
-
|
|
50
|
-
const metaKeysToValues = {
|
|
51
|
-
album,
|
|
52
|
-
artist,
|
|
53
|
-
album_artist: artist,
|
|
54
|
-
title,
|
|
55
|
-
disc,
|
|
56
|
-
track,
|
|
57
|
-
"episode-type": episodeType,
|
|
58
|
-
date,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
if (!isWin) {
|
|
62
|
-
// Due to limited escape options, these metadata fields often break in Windows
|
|
63
|
-
metaKeysToValues.comment = comment;
|
|
64
|
-
metaKeysToValues.subtitle = subtitle
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const metadataString = Object.keys(metaKeysToValues)
|
|
68
|
-
.map((key) => {
|
|
69
|
-
if (!metaKeysToValues[key]) {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const argValue = escapeArgForShell(metaKeysToValues[key]);
|
|
74
|
-
|
|
75
|
-
return argValue ? `-metadata ${key}=${argValue}` : null;
|
|
76
|
-
})
|
|
77
|
-
.filter((segment) => !!segment)
|
|
78
|
-
.join(" ");
|
|
79
|
-
|
|
80
|
-
command += ` -map_metadata 0 ${metadataString} -codec copy`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (shouldEmbedImage) {
|
|
84
|
-
command += ` -map 0 -map 1`;
|
|
85
|
-
} else {
|
|
86
|
-
command += ` -map 0`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const tmpMp3Path = `${outputPath}.tmp${ext}`;
|
|
90
|
-
command += ` ${escapeArgForShell(tmpMp3Path)}`;
|
|
91
|
-
logMessage("Running command: " + command, LOG_LEVELS.debug);
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
await execWithPromise(command, { stdio: "ignore" });
|
|
95
|
-
} catch (error) {
|
|
96
|
-
if (fs.existsSync(tmpMp3Path)) {
|
|
97
|
-
fs.unlinkSync(tmpMp3Path);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
throw error;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
fs.unlinkSync(outputPath);
|
|
104
|
-
fs.renameSync(tmpMp3Path, outputPath);
|
|
105
|
-
};
|
|
1
|
+
import dayjs from "dayjs";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { execWithPromise } from "./exec.js";
|
|
4
|
+
import { LOG_LEVELS, logMessage } from "./logger.js";
|
|
5
|
+
import { escapeArgForShell, isWin } from "./util.js";
|
|
6
|
+
|
|
7
|
+
export const runFfmpeg = async ({
|
|
8
|
+
feed,
|
|
9
|
+
item,
|
|
10
|
+
itemIndex,
|
|
11
|
+
outputPath,
|
|
12
|
+
episodeImageOutputPath,
|
|
13
|
+
bitrate,
|
|
14
|
+
mono,
|
|
15
|
+
addMp3Metadata,
|
|
16
|
+
ext,
|
|
17
|
+
}) => {
|
|
18
|
+
if (!fs.existsSync(outputPath)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const shouldEmbedImage = addMp3Metadata && episodeImageOutputPath;
|
|
23
|
+
let command = `ffmpeg -loglevel quiet -i ${escapeArgForShell(outputPath)}`;
|
|
24
|
+
|
|
25
|
+
if (shouldEmbedImage) {
|
|
26
|
+
command += ` -i ${escapeArgForShell(episodeImageOutputPath)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (bitrate) {
|
|
30
|
+
command += ` -b:a ${bitrate}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (mono) {
|
|
34
|
+
command += " -ac 1";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (addMp3Metadata) {
|
|
38
|
+
const album = feed.title || "";
|
|
39
|
+
const artist = item.itunes?.author || item.author || "";
|
|
40
|
+
const title = item.title || "";
|
|
41
|
+
const subtitle = item.itunes?.subtitle || "";
|
|
42
|
+
const comment = item.contentSnippet || item.content || "";
|
|
43
|
+
const disc = item.itunes?.season || "";
|
|
44
|
+
const track = item.itunes?.episode || `${feed.items.length - itemIndex}`;
|
|
45
|
+
const episodeType = item.itunes?.episodeType || "";
|
|
46
|
+
const date = item.pubDate
|
|
47
|
+
? dayjs(new Date(item.pubDate)).format("YYYY-MM-DD")
|
|
48
|
+
: "";
|
|
49
|
+
|
|
50
|
+
const metaKeysToValues = {
|
|
51
|
+
album,
|
|
52
|
+
artist,
|
|
53
|
+
album_artist: artist,
|
|
54
|
+
title,
|
|
55
|
+
disc,
|
|
56
|
+
track,
|
|
57
|
+
"episode-type": episodeType,
|
|
58
|
+
date,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!isWin) {
|
|
62
|
+
// Due to limited escape options, these metadata fields often break in Windows
|
|
63
|
+
metaKeysToValues.comment = comment;
|
|
64
|
+
metaKeysToValues.subtitle = subtitle
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const metadataString = Object.keys(metaKeysToValues)
|
|
68
|
+
.map((key) => {
|
|
69
|
+
if (!metaKeysToValues[key]) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const argValue = escapeArgForShell(metaKeysToValues[key]);
|
|
74
|
+
|
|
75
|
+
return argValue ? `-metadata ${key}=${argValue}` : null;
|
|
76
|
+
})
|
|
77
|
+
.filter((segment) => !!segment)
|
|
78
|
+
.join(" ");
|
|
79
|
+
|
|
80
|
+
command += ` -map_metadata 0 ${metadataString} -codec copy`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (shouldEmbedImage) {
|
|
84
|
+
command += ` -map 0 -map 1`;
|
|
85
|
+
} else {
|
|
86
|
+
command += ` -map 0`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const tmpMp3Path = `${outputPath}.tmp${ext}`;
|
|
90
|
+
command += ` ${escapeArgForShell(tmpMp3Path)}`;
|
|
91
|
+
logMessage("Running command: " + command, LOG_LEVELS.debug);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await execWithPromise(command, { stdio: "ignore" });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (fs.existsSync(tmpMp3Path)) {
|
|
97
|
+
fs.unlinkSync(tmpMp3Path);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fs.unlinkSync(outputPath);
|
|
104
|
+
fs.renameSync(tmpMp3Path, outputPath);
|
|
105
|
+
};
|