yt2text 0.1.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 -0
- package/README.md +334 -0
- package/dist/asr.js +19 -0
- package/dist/cache.js +28 -0
- package/dist/cli.js +384 -0
- package/dist/config.js +185 -0
- package/dist/cookies.js +121 -0
- package/dist/deps.js +82 -0
- package/dist/doctor.js +140 -0
- package/dist/download-file.js +42 -0
- package/dist/errors.js +68 -0
- package/dist/local-asr.js +218 -0
- package/dist/logger.js +32 -0
- package/dist/media.js +224 -0
- package/dist/output.js +62 -0
- package/dist/platform.js +39 -0
- package/dist/process.js +69 -0
- package/dist/progress.js +79 -0
- package/dist/segments.js +36 -0
- package/dist/system-asr.js +152 -0
- package/dist/types.js +1 -0
- package/helpers/macos-speech-info.plist +21 -0
- package/helpers/macos-transcribe.swift +130 -0
- package/helpers/windows-transcribe.ps1 +45 -0
- package/package.json +62 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
6
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
7
|
+
import { createRuntimePaths } from "./cache.js";
|
|
8
|
+
import { defaultConfigPath, expandHome, loadConfig, writeDefaultConfig } from "./config.js";
|
|
9
|
+
import { defaultCookiesFromBrowser, detectCookieBrowsers, knownCookieBrowsers } from "./cookies.js";
|
|
10
|
+
import { runDoctor } from "./doctor.js";
|
|
11
|
+
import { downloadMedia, normalizeToWav, prepareLocalMedia } from "./media.js";
|
|
12
|
+
import { installLogger } from "./logger.js";
|
|
13
|
+
import { defaultAsrProvider, defaultSystemMode, isSupportedPlatform, platformName } from "./platform.js";
|
|
14
|
+
import { assignSpeakers } from "./segments.js";
|
|
15
|
+
import { transcribe } from "./asr.js";
|
|
16
|
+
import { diarizeWithLocal } from "./local-asr.js";
|
|
17
|
+
import { renderOutputStem, writeTranscript } from "./output.js";
|
|
18
|
+
import { explainError, Yt2TextError } from "./errors.js";
|
|
19
|
+
const defaultLanguage = process.env.YT2TEXT_LANGUAGE ?? "zh-CN";
|
|
20
|
+
function defaultOutputDir() {
|
|
21
|
+
return process.env.YT2TEXT_OUTPUT_DIR ?? expandHome("~/Downloads");
|
|
22
|
+
}
|
|
23
|
+
function parseChoice(value, choices) {
|
|
24
|
+
if (choices.includes(value))
|
|
25
|
+
return value;
|
|
26
|
+
throw new InvalidArgumentError(`Expected one of: ${choices.join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
function parseIntegerOption(name, min) {
|
|
29
|
+
return (value) => {
|
|
30
|
+
const parsed = Number(value);
|
|
31
|
+
if (!Number.isInteger(parsed) || parsed < min)
|
|
32
|
+
throw new InvalidArgumentError(`${name} must be an integer >= ${min}`);
|
|
33
|
+
return parsed;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function parseNumberOption(name, min) {
|
|
37
|
+
return (value) => {
|
|
38
|
+
const parsed = Number(value);
|
|
39
|
+
if (!Number.isFinite(parsed) || parsed < min)
|
|
40
|
+
throw new InvalidArgumentError(`${name} must be a number >= ${min}`);
|
|
41
|
+
return parsed;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function commandValue(command, config, optionName, configName = optionName) {
|
|
45
|
+
const opts = command.opts();
|
|
46
|
+
const source = command.getOptionValueSource(optionName);
|
|
47
|
+
const configured = config[configName];
|
|
48
|
+
return source !== "cli" && configured !== undefined ? configured : opts[optionName];
|
|
49
|
+
}
|
|
50
|
+
async function collectOptions(command) {
|
|
51
|
+
const raw = command.opts();
|
|
52
|
+
const configPath = expandHome(String(raw.config ?? defaultConfigPath()));
|
|
53
|
+
const config = await loadConfig(configPath);
|
|
54
|
+
const language = String(commandValue(command, config, "language") ?? defaultLanguage);
|
|
55
|
+
const multilingual = Boolean(raw.multilingual) || language.toLowerCase() === "auto";
|
|
56
|
+
const cookies = commandValue(command, config, "cookies");
|
|
57
|
+
const requestedBrowser = commandValue(command, config, "cookiesFromBrowser");
|
|
58
|
+
const browserCookies = commandValue(command, config, "browserCookies") ?? true;
|
|
59
|
+
const parallelDownloads = command.getOptionValueSource("concurrentFragments") === "cli"
|
|
60
|
+
? commandValue(command, config, "concurrentFragments")
|
|
61
|
+
: command.getOptionValueSource("parallelDownloads") === "cli"
|
|
62
|
+
? commandValue(command, config, "parallelDownloads")
|
|
63
|
+
: config.concurrentFragments ?? config.parallelDownloads ?? commandValue(command, config, "concurrentFragments");
|
|
64
|
+
return {
|
|
65
|
+
configPath,
|
|
66
|
+
asr: multilingual ? "local" : commandValue(command, config, "asr"),
|
|
67
|
+
systemMode: commandValue(command, config, "systemMode"),
|
|
68
|
+
fallback: commandValue(command, config, "fallback"),
|
|
69
|
+
language: multilingual ? "auto" : language,
|
|
70
|
+
model: commandValue(command, config, "model"),
|
|
71
|
+
diarize: Boolean(commandValue(command, config, "diarize")),
|
|
72
|
+
format: commandValue(command, config, "format"),
|
|
73
|
+
outputDir: expandHome(String(commandValue(command, config, "output", "outputDir") ?? defaultOutputDir())),
|
|
74
|
+
cacheDir: commandValue(command, config, "cacheDir") ? expandHome(String(commandValue(command, config, "cacheDir"))) : undefined,
|
|
75
|
+
offline: Boolean(commandValue(command, config, "offline")),
|
|
76
|
+
keepAudio: Boolean(commandValue(command, config, "keepAudio")),
|
|
77
|
+
keepOriginalAudio: Boolean(commandValue(command, config, "keepOriginalAudio")),
|
|
78
|
+
chunkSeconds: Number(commandValue(command, config, "chunkSeconds")),
|
|
79
|
+
filenameTemplate: String(commandValue(command, config, "filenameTemplate") ?? "{title}"),
|
|
80
|
+
audioQuality: commandValue(command, config, "audioQuality"),
|
|
81
|
+
verbose: Boolean(commandValue(command, config, "verbose")),
|
|
82
|
+
cookies: cookies ? expandHome(cookies) : undefined,
|
|
83
|
+
cookiesFromBrowser: cookies ? undefined : requestedBrowser ?? (browserCookies === false ? undefined : defaultCookiesFromBrowser()),
|
|
84
|
+
browserCookies,
|
|
85
|
+
concurrentFragments: Number(parallelDownloads),
|
|
86
|
+
retries: Number(commandValue(command, config, "retries")),
|
|
87
|
+
fragmentRetries: Number(commandValue(command, config, "fragmentRetries")),
|
|
88
|
+
socketTimeout: Number(commandValue(command, config, "socketTimeout")),
|
|
89
|
+
downloadTimeoutSeconds: Number(commandValue(command, config, "downloadTimeout", "downloadTimeoutSeconds")),
|
|
90
|
+
convertTimeoutSeconds: Number(commandValue(command, config, "convertTimeout", "convertTimeoutSeconds")),
|
|
91
|
+
asrChunkTimeoutSeconds: Number(commandValue(command, config, "asrChunkTimeout", "asrChunkTimeoutSeconds")),
|
|
92
|
+
proxy: commandValue(command, config, "proxy"),
|
|
93
|
+
rateLimit: commandValue(command, config, "rateLimit"),
|
|
94
|
+
userAgent: commandValue(command, config, "userAgent"),
|
|
95
|
+
logFile: commandValue(command, config, "logFile") ? expandHome(String(commandValue(command, config, "logFile"))) : undefined,
|
|
96
|
+
updateYtDlp: Boolean(commandValue(command, config, "updateYtDlp")),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function addCommonOptions(command) {
|
|
100
|
+
return command
|
|
101
|
+
.option("--config <file>", "JSON config file", defaultConfigPath())
|
|
102
|
+
.option("--asr <provider>", "ASR provider: system or local", (value) => parseChoice(value, ["system", "local"]), defaultAsrProvider())
|
|
103
|
+
.option("--system-mode <mode>", "System ASR mode: online, offline, or auto", (value) => parseChoice(value, ["online", "offline", "auto"]), defaultSystemMode())
|
|
104
|
+
.option("--fallback <mode>", "Fallback when system ASR fails: local or fail", (value) => parseChoice(value, ["local", "fail"]), "fail")
|
|
105
|
+
.option("-l, --language <locale>", "Recognition locale, or auto for local multilingual ASR", defaultLanguage)
|
|
106
|
+
.option("--multilingual", "Use local ASR with automatic language detection", false)
|
|
107
|
+
.option("--model <model>", "Local ASR model", (value) => parseChoice(value, ["tiny", "base", "small", "medium"]), "small")
|
|
108
|
+
.option("--diarize", "Add anonymous speaker labels using local diarization", false)
|
|
109
|
+
.option("-f, --format <format>", "Output format", (value) => parseChoice(value, ["txt", "json", "srt"]), "txt")
|
|
110
|
+
.option("-o, --output <dir>", "Output directory", defaultOutputDir())
|
|
111
|
+
.option("--cache-dir <dir>", "Override cache directory")
|
|
112
|
+
.option("--offline", "Do not download missing tools or models", false)
|
|
113
|
+
.option("--keep-audio", "Keep normalized WAV next to the transcript", false)
|
|
114
|
+
.option("--keep-original-audio", "Keep the original downloaded audio next to the transcript", false)
|
|
115
|
+
.option("--filename-template <template>", "Output filename template, e.g. {date}-{title}", "{title}")
|
|
116
|
+
.option("--audio-quality <quality>", "Downloaded audio quality: best, balanced, or small", (value) => parseChoice(value, ["best", "balanced", "small"]), "best")
|
|
117
|
+
.option("--cookies <file>", "Pass a Netscape cookies file to yt-dlp")
|
|
118
|
+
.option("--cookies-from-browser <browser>", "Read cookies from a browser, e.g. auto, chrome, safari, firefox")
|
|
119
|
+
.option("--no-browser-cookies", "Do not automatically read browser cookies")
|
|
120
|
+
.option("--parallel-downloads <n>", "Alias for yt-dlp parallel fragment downloads", parseIntegerOption("parallel downloads", 1))
|
|
121
|
+
.option("--concurrent-fragments <n>", "yt-dlp fragment concurrency", parseIntegerOption("concurrent fragments", 1), 4)
|
|
122
|
+
.option("--retries <n>", "yt-dlp download retries", parseIntegerOption("retries", 0), 10)
|
|
123
|
+
.option("--fragment-retries <n>", "yt-dlp fragment retries", parseIntegerOption("fragment retries", 0), 10)
|
|
124
|
+
.option("--socket-timeout <seconds>", "yt-dlp socket timeout", parseNumberOption("socket timeout", 1), 20)
|
|
125
|
+
.option("--download-timeout <seconds>", "Fail yt-dlp if download exceeds this time; 0 disables", parseNumberOption("download timeout", 0), 1800)
|
|
126
|
+
.option("--convert-timeout <seconds>", "Fail ffmpeg conversion/splitting if it exceeds this time; 0 disables", parseNumberOption("convert timeout", 0), 600)
|
|
127
|
+
.option("--asr-chunk-timeout <seconds>", "Fail each system ASR chunk if it exceeds this time; 0 disables", parseNumberOption("ASR chunk timeout", 0), 180)
|
|
128
|
+
.option("--proxy <url>", "Proxy URL for yt-dlp")
|
|
129
|
+
.option("--rate-limit <rate>", "yt-dlp rate limit, e.g. 2M or 500K")
|
|
130
|
+
.option("--user-agent <ua>", "Custom yt-dlp user agent")
|
|
131
|
+
.option("--log-file <file>", "Append logs to a file")
|
|
132
|
+
.option("--update-ytdlp", "Force refresh cached yt-dlp before downloading", false)
|
|
133
|
+
.option("--chunk-seconds <seconds>", "System ASR chunk size", (value) => {
|
|
134
|
+
const parsed = Number(value);
|
|
135
|
+
if (!Number.isFinite(parsed) || parsed < 10)
|
|
136
|
+
throw new InvalidArgumentError("Expected a number >= 10");
|
|
137
|
+
return parsed;
|
|
138
|
+
}, 55)
|
|
139
|
+
.option("-v, --verbose", "Verbose logging", false);
|
|
140
|
+
}
|
|
141
|
+
function isUrl(value) {
|
|
142
|
+
return /^https?:\/\//i.test(value);
|
|
143
|
+
}
|
|
144
|
+
function progress(message) {
|
|
145
|
+
console.error(message);
|
|
146
|
+
}
|
|
147
|
+
async function runPipeline(input, inputKind, options) {
|
|
148
|
+
if (!isSupportedPlatform()) {
|
|
149
|
+
throw new Yt2TextError(`yt2text only supports macOS, Linux, and Windows. Current platform: ${platformName()} (${process.platform}).`, "UNSUPPORTED_PLATFORM");
|
|
150
|
+
}
|
|
151
|
+
if (options.verbose) {
|
|
152
|
+
console.error(`ASR: ${options.asr}; language: ${options.language}; system mode: ${options.systemMode}`);
|
|
153
|
+
}
|
|
154
|
+
const paths = await createRuntimePaths(options);
|
|
155
|
+
try {
|
|
156
|
+
progress(inputKind === "url" ? "[1/5] Downloading audio..." : "[1/5] Preparing local media...");
|
|
157
|
+
const media = inputKind === "url" ? await downloadMedia(input, paths, options) : await prepareLocalMedia(expandHome(input), paths);
|
|
158
|
+
progress(`[2/5] Converting audio: ${media.title}`);
|
|
159
|
+
const normalized = join(paths.workDir, "audio.16k.wav");
|
|
160
|
+
await normalizeToWav(media.mediaPath, normalized, options.convertTimeoutSeconds);
|
|
161
|
+
progress(`[3/5] Transcribing with ${options.asr} ASR (${options.language})...`);
|
|
162
|
+
const transcript = await transcribe(normalized, paths, options);
|
|
163
|
+
progress(`[4/5] Preparing transcript${options.diarize ? " and speaker labels" : ""}...`);
|
|
164
|
+
const segments = options.diarize
|
|
165
|
+
? assignSpeakers(transcript.segments, await diarizeWithLocal(normalized, paths, options))
|
|
166
|
+
: transcript.segments;
|
|
167
|
+
const documentBase = {
|
|
168
|
+
source: input,
|
|
169
|
+
title: media.title,
|
|
170
|
+
engine: transcript.engine,
|
|
171
|
+
language: options.language,
|
|
172
|
+
segments,
|
|
173
|
+
createdAt: new Date().toISOString(),
|
|
174
|
+
};
|
|
175
|
+
const outputStem = renderOutputStem(documentBase, options.filenameTemplate);
|
|
176
|
+
const audioPath = options.keepAudio ? join(options.outputDir, `${outputStem}.wav`) : undefined;
|
|
177
|
+
if (audioPath) {
|
|
178
|
+
const { copyFile } = await import("node:fs/promises");
|
|
179
|
+
await copyFile(normalized, audioPath);
|
|
180
|
+
}
|
|
181
|
+
let originalAudioPath;
|
|
182
|
+
if (options.keepOriginalAudio) {
|
|
183
|
+
const { copyFile } = await import("node:fs/promises");
|
|
184
|
+
const { extname } = await import("node:path");
|
|
185
|
+
const extension = extname(media.mediaPath) || ".audio";
|
|
186
|
+
originalAudioPath = join(options.outputDir, `${outputStem}${extension}`);
|
|
187
|
+
await copyFile(media.mediaPath, originalAudioPath);
|
|
188
|
+
}
|
|
189
|
+
progress(`[5/5] Writing ${options.format} to ${options.outputDir}...`);
|
|
190
|
+
const outputPath = await writeTranscript({
|
|
191
|
+
...documentBase,
|
|
192
|
+
audioPath,
|
|
193
|
+
originalAudioPath,
|
|
194
|
+
}, options.outputDir, options.format, options.filenameTemplate);
|
|
195
|
+
progress(`Done: ${outputPath}`);
|
|
196
|
+
console.log(outputPath);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await rm(paths.workDir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function askRequired(rl, question) {
|
|
203
|
+
while (true) {
|
|
204
|
+
const value = (await rl.question(question)).trim();
|
|
205
|
+
if (value)
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function askDefault(rl, question, defaultValue) {
|
|
210
|
+
const value = (await rl.question(`${question} [${defaultValue}]: `)).trim();
|
|
211
|
+
return value || defaultValue;
|
|
212
|
+
}
|
|
213
|
+
async function askChoice(rl, question, choices, defaultIndex = 0) {
|
|
214
|
+
console.error(question);
|
|
215
|
+
choices.forEach((choice, index) => {
|
|
216
|
+
const marker = index === defaultIndex ? "*" : " ";
|
|
217
|
+
console.error(` ${index + 1}. ${marker} ${choice.label}`);
|
|
218
|
+
});
|
|
219
|
+
while (true) {
|
|
220
|
+
const answer = (await rl.question(`Choose [${defaultIndex + 1}]: `)).trim();
|
|
221
|
+
if (!answer)
|
|
222
|
+
return choices[defaultIndex].value;
|
|
223
|
+
const index = Number(answer) - 1;
|
|
224
|
+
if (Number.isInteger(index) && choices[index])
|
|
225
|
+
return choices[index].value;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function runInteractive() {
|
|
229
|
+
const rl = createInterface({ input, output });
|
|
230
|
+
try {
|
|
231
|
+
const config = await loadConfig();
|
|
232
|
+
console.error("yt2text interactive mode");
|
|
233
|
+
const source = expandHome(await askRequired(rl, "YouTube URL or local file path: "));
|
|
234
|
+
const inputKind = isUrl(source) ? "url" : "file";
|
|
235
|
+
const languageChoice = await askChoice(rl, "Recognition language", [
|
|
236
|
+
{ label: "Chinese (zh-CN)", value: "zh-CN" },
|
|
237
|
+
{ label: "English (en-US)", value: "en-US" },
|
|
238
|
+
{ label: "Auto / multilingual (local ASR)", value: "auto" },
|
|
239
|
+
{ label: "Custom locale", value: "custom" },
|
|
240
|
+
]);
|
|
241
|
+
const languageDefault = config.language ?? defaultLanguage;
|
|
242
|
+
const language = languageChoice === "custom" ? await askDefault(rl, "Locale", languageDefault) : languageChoice;
|
|
243
|
+
const multilingual = language === "auto";
|
|
244
|
+
const outputDir = expandHome(await askDefault(rl, "Output directory", config.outputDir ?? defaultOutputDir()));
|
|
245
|
+
let cookies;
|
|
246
|
+
let cookiesFromBrowser;
|
|
247
|
+
let browserCookies = true;
|
|
248
|
+
if (inputKind === "url") {
|
|
249
|
+
const cookieMode = await askChoice(rl, "YouTube cookies", [
|
|
250
|
+
{ label: "Auto-detect browser cookies", value: "auto" },
|
|
251
|
+
{ label: "Choose a browser", value: "browser" },
|
|
252
|
+
{ label: "Manual cookies file", value: "file" },
|
|
253
|
+
{ label: "No cookies", value: "none" },
|
|
254
|
+
]);
|
|
255
|
+
if (cookieMode === "auto") {
|
|
256
|
+
cookiesFromBrowser = "auto";
|
|
257
|
+
}
|
|
258
|
+
else if (cookieMode === "browser") {
|
|
259
|
+
const detected = await detectCookieBrowsers();
|
|
260
|
+
const detectedIds = new Set(detected.map((browser) => browser.id));
|
|
261
|
+
const browsers = knownCookieBrowsers().map((browser) => ({
|
|
262
|
+
label: detectedIds.has(browser.id) ? `${browser.name} (${browser.id}, detected)` : `${browser.name} (${browser.id})`,
|
|
263
|
+
value: browser.id,
|
|
264
|
+
}));
|
|
265
|
+
cookiesFromBrowser = await askChoice(rl, "Browser", browsers);
|
|
266
|
+
}
|
|
267
|
+
else if (cookieMode === "file") {
|
|
268
|
+
cookies = expandHome(await askRequired(rl, "Cookies file path: "));
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
browserCookies = false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const options = {
|
|
275
|
+
configPath: defaultConfigPath(),
|
|
276
|
+
asr: multilingual ? "local" : config.asr ?? defaultAsrProvider(),
|
|
277
|
+
systemMode: config.systemMode ?? defaultSystemMode(),
|
|
278
|
+
fallback: config.fallback ?? "fail",
|
|
279
|
+
language,
|
|
280
|
+
model: config.model ?? "small",
|
|
281
|
+
diarize: config.diarize ?? false,
|
|
282
|
+
format: config.format ?? "txt",
|
|
283
|
+
outputDir,
|
|
284
|
+
cacheDir: config.cacheDir ? expandHome(config.cacheDir) : undefined,
|
|
285
|
+
offline: config.offline ?? false,
|
|
286
|
+
keepAudio: config.keepAudio ?? false,
|
|
287
|
+
keepOriginalAudio: config.keepOriginalAudio ?? false,
|
|
288
|
+
chunkSeconds: config.chunkSeconds ?? 55,
|
|
289
|
+
filenameTemplate: config.filenameTemplate ?? "{title}",
|
|
290
|
+
audioQuality: config.audioQuality ?? "best",
|
|
291
|
+
verbose: config.verbose ?? false,
|
|
292
|
+
cookies,
|
|
293
|
+
cookiesFromBrowser,
|
|
294
|
+
browserCookies,
|
|
295
|
+
concurrentFragments: config.parallelDownloads ?? config.concurrentFragments ?? 4,
|
|
296
|
+
retries: config.retries ?? 10,
|
|
297
|
+
fragmentRetries: config.fragmentRetries ?? 10,
|
|
298
|
+
socketTimeout: config.socketTimeout ?? 20,
|
|
299
|
+
downloadTimeoutSeconds: config.downloadTimeoutSeconds ?? 1800,
|
|
300
|
+
convertTimeoutSeconds: config.convertTimeoutSeconds ?? 600,
|
|
301
|
+
asrChunkTimeoutSeconds: config.asrChunkTimeoutSeconds ?? 180,
|
|
302
|
+
proxy: config.proxy,
|
|
303
|
+
rateLimit: config.rateLimit,
|
|
304
|
+
userAgent: config.userAgent,
|
|
305
|
+
logFile: config.logFile ? expandHome(config.logFile) : undefined,
|
|
306
|
+
updateYtDlp: config.updateYtDlp ?? false,
|
|
307
|
+
};
|
|
308
|
+
await installLogger(options.logFile);
|
|
309
|
+
await runPipeline(source, inputKind, options);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
rl.close();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const program = new Command();
|
|
316
|
+
program
|
|
317
|
+
.name("yt2text")
|
|
318
|
+
.description("Download audio and save local transcripts.")
|
|
319
|
+
.version("0.1.0");
|
|
320
|
+
const runCommand = addCommonOptions(program.command("run").argument("<url>", "YouTube or yt-dlp supported URL").description("Download and transcribe a URL"));
|
|
321
|
+
runCommand.action(async (url) => {
|
|
322
|
+
const options = await collectOptions(runCommand);
|
|
323
|
+
await installLogger(options.logFile);
|
|
324
|
+
await runPipeline(url, "url", options);
|
|
325
|
+
});
|
|
326
|
+
const fileCommand = addCommonOptions(program.command("file").argument("<path>", "Local audio/video file"));
|
|
327
|
+
fileCommand.action(async (filePath) => {
|
|
328
|
+
const options = await collectOptions(fileCommand);
|
|
329
|
+
await installLogger(options.logFile);
|
|
330
|
+
await runPipeline(expandHome(filePath), "file", options);
|
|
331
|
+
});
|
|
332
|
+
const doctorCommand = addCommonOptions(program.command("doctor").description("Check local dependencies and cache"));
|
|
333
|
+
doctorCommand.option("--fix", "Download missing small dependencies where possible", false);
|
|
334
|
+
doctorCommand.option("--show-config", "Print the effective options after config and CLI overrides", false);
|
|
335
|
+
doctorCommand.action(async () => {
|
|
336
|
+
const options = await collectOptions(doctorCommand);
|
|
337
|
+
await installLogger(options.logFile);
|
|
338
|
+
const paths = await createRuntimePaths(options);
|
|
339
|
+
try {
|
|
340
|
+
const doctorOptions = doctorCommand.opts();
|
|
341
|
+
await runDoctor(paths, options, Boolean(doctorOptions.fix), Boolean(doctorOptions.showConfig));
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
await rm(paths.workDir, { recursive: true, force: true });
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
const configCommand = program
|
|
348
|
+
.command("config")
|
|
349
|
+
.argument("[path]", "Where to write config JSON", defaultConfigPath())
|
|
350
|
+
.option("--print", "Print the default config JSON instead of writing a file", false)
|
|
351
|
+
.description("Write a default JSON config file");
|
|
352
|
+
configCommand.action(async (path) => {
|
|
353
|
+
if (configCommand.opts().print) {
|
|
354
|
+
const { defaultConfig } = await import("./config.js");
|
|
355
|
+
console.log(JSON.stringify(defaultConfig(), null, 2));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const target = await writeDefaultConfig(path);
|
|
359
|
+
console.log(target);
|
|
360
|
+
});
|
|
361
|
+
function withDefaultCommand(argv) {
|
|
362
|
+
const knownCommands = new Set(["run", "file", "doctor", "config", "help"]);
|
|
363
|
+
const first = argv[0];
|
|
364
|
+
if (!first || first === "-h" || first === "--help" || first === "-V" || first === "--version")
|
|
365
|
+
return argv;
|
|
366
|
+
if (knownCommands.has(first))
|
|
367
|
+
return argv;
|
|
368
|
+
return ["run", ...argv];
|
|
369
|
+
}
|
|
370
|
+
async function main() {
|
|
371
|
+
const argv = process.argv.slice(2);
|
|
372
|
+
if (argv.length === 0) {
|
|
373
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
374
|
+
await runInteractive();
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
program.help();
|
|
378
|
+
}
|
|
379
|
+
await program.parseAsync(["node", "yt2text", ...withDefaultCommand(argv)]);
|
|
380
|
+
}
|
|
381
|
+
main().catch((error) => {
|
|
382
|
+
console.error(explainError(error));
|
|
383
|
+
process.exitCode = 1;
|
|
384
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { Yt2TextError } from "./errors.js";
|
|
5
|
+
import { defaultAsrProvider, defaultSystemMode } from "./platform.js";
|
|
6
|
+
const knownConfigKeys = new Set([
|
|
7
|
+
"asr",
|
|
8
|
+
"systemMode",
|
|
9
|
+
"fallback",
|
|
10
|
+
"language",
|
|
11
|
+
"model",
|
|
12
|
+
"diarize",
|
|
13
|
+
"format",
|
|
14
|
+
"outputDir",
|
|
15
|
+
"cacheDir",
|
|
16
|
+
"offline",
|
|
17
|
+
"keepAudio",
|
|
18
|
+
"keepOriginalAudio",
|
|
19
|
+
"chunkSeconds",
|
|
20
|
+
"filenameTemplate",
|
|
21
|
+
"audioQuality",
|
|
22
|
+
"verbose",
|
|
23
|
+
"cookies",
|
|
24
|
+
"cookiesFromBrowser",
|
|
25
|
+
"browserCookies",
|
|
26
|
+
"parallelDownloads",
|
|
27
|
+
"concurrentFragments",
|
|
28
|
+
"retries",
|
|
29
|
+
"fragmentRetries",
|
|
30
|
+
"socketTimeout",
|
|
31
|
+
"downloadTimeoutSeconds",
|
|
32
|
+
"convertTimeoutSeconds",
|
|
33
|
+
"asrChunkTimeoutSeconds",
|
|
34
|
+
"proxy",
|
|
35
|
+
"rateLimit",
|
|
36
|
+
"userAgent",
|
|
37
|
+
"logFile",
|
|
38
|
+
"updateYtDlp",
|
|
39
|
+
]);
|
|
40
|
+
function optional(value) {
|
|
41
|
+
return value === undefined || value === null;
|
|
42
|
+
}
|
|
43
|
+
function configError(path, key, expected) {
|
|
44
|
+
return new Yt2TextError(`Invalid config ${path}: ${key} must be ${expected}`, "INVALID_CONFIG");
|
|
45
|
+
}
|
|
46
|
+
function enumValue(path, key, value, choices) {
|
|
47
|
+
if (optional(value))
|
|
48
|
+
return undefined;
|
|
49
|
+
if (typeof value === "string" && choices.includes(value))
|
|
50
|
+
return value;
|
|
51
|
+
throw configError(path, key, `one of ${choices.join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
function stringValue(path, key, value) {
|
|
54
|
+
if (optional(value))
|
|
55
|
+
return undefined;
|
|
56
|
+
if (typeof value === "string")
|
|
57
|
+
return value;
|
|
58
|
+
throw configError(path, key, "a string");
|
|
59
|
+
}
|
|
60
|
+
function booleanValue(path, key, value) {
|
|
61
|
+
if (optional(value))
|
|
62
|
+
return undefined;
|
|
63
|
+
if (typeof value === "boolean")
|
|
64
|
+
return value;
|
|
65
|
+
throw configError(path, key, "a boolean");
|
|
66
|
+
}
|
|
67
|
+
function numberValue(path, key, value, min) {
|
|
68
|
+
if (optional(value))
|
|
69
|
+
return undefined;
|
|
70
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= min)
|
|
71
|
+
return value;
|
|
72
|
+
throw configError(path, key, `a number >= ${min}`);
|
|
73
|
+
}
|
|
74
|
+
function integerValue(path, key, value, min) {
|
|
75
|
+
if (optional(value))
|
|
76
|
+
return undefined;
|
|
77
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= min)
|
|
78
|
+
return value;
|
|
79
|
+
throw configError(path, key, `an integer >= ${min}`);
|
|
80
|
+
}
|
|
81
|
+
function normalizeConfig(raw, path) {
|
|
82
|
+
for (const key of Object.keys(raw)) {
|
|
83
|
+
if (!knownConfigKeys.has(key)) {
|
|
84
|
+
throw new Yt2TextError(`Invalid config ${path}: unknown field "${key}"`, "INVALID_CONFIG");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
asr: enumValue(path, "asr", raw.asr, ["system", "local"]),
|
|
89
|
+
systemMode: enumValue(path, "systemMode", raw.systemMode, ["online", "offline", "auto"]),
|
|
90
|
+
fallback: enumValue(path, "fallback", raw.fallback, ["local", "fail"]),
|
|
91
|
+
language: stringValue(path, "language", raw.language),
|
|
92
|
+
model: enumValue(path, "model", raw.model, ["tiny", "base", "small", "medium"]),
|
|
93
|
+
diarize: booleanValue(path, "diarize", raw.diarize),
|
|
94
|
+
format: enumValue(path, "format", raw.format, ["txt", "json", "srt"]),
|
|
95
|
+
outputDir: stringValue(path, "outputDir", raw.outputDir),
|
|
96
|
+
cacheDir: stringValue(path, "cacheDir", raw.cacheDir),
|
|
97
|
+
offline: booleanValue(path, "offline", raw.offline),
|
|
98
|
+
keepAudio: booleanValue(path, "keepAudio", raw.keepAudio),
|
|
99
|
+
keepOriginalAudio: booleanValue(path, "keepOriginalAudio", raw.keepOriginalAudio),
|
|
100
|
+
chunkSeconds: numberValue(path, "chunkSeconds", raw.chunkSeconds, 10),
|
|
101
|
+
filenameTemplate: stringValue(path, "filenameTemplate", raw.filenameTemplate),
|
|
102
|
+
audioQuality: enumValue(path, "audioQuality", raw.audioQuality, ["best", "balanced", "small"]),
|
|
103
|
+
verbose: booleanValue(path, "verbose", raw.verbose),
|
|
104
|
+
cookies: stringValue(path, "cookies", raw.cookies),
|
|
105
|
+
cookiesFromBrowser: stringValue(path, "cookiesFromBrowser", raw.cookiesFromBrowser),
|
|
106
|
+
browserCookies: booleanValue(path, "browserCookies", raw.browserCookies),
|
|
107
|
+
parallelDownloads: integerValue(path, "parallelDownloads", raw.parallelDownloads, 1),
|
|
108
|
+
concurrentFragments: integerValue(path, "concurrentFragments", raw.concurrentFragments, 1),
|
|
109
|
+
retries: integerValue(path, "retries", raw.retries, 0),
|
|
110
|
+
fragmentRetries: integerValue(path, "fragmentRetries", raw.fragmentRetries, 0),
|
|
111
|
+
socketTimeout: numberValue(path, "socketTimeout", raw.socketTimeout, 1),
|
|
112
|
+
downloadTimeoutSeconds: numberValue(path, "downloadTimeoutSeconds", raw.downloadTimeoutSeconds, 0),
|
|
113
|
+
convertTimeoutSeconds: numberValue(path, "convertTimeoutSeconds", raw.convertTimeoutSeconds, 0),
|
|
114
|
+
asrChunkTimeoutSeconds: numberValue(path, "asrChunkTimeoutSeconds", raw.asrChunkTimeoutSeconds, 0),
|
|
115
|
+
proxy: stringValue(path, "proxy", raw.proxy),
|
|
116
|
+
rateLimit: stringValue(path, "rateLimit", raw.rateLimit),
|
|
117
|
+
userAgent: stringValue(path, "userAgent", raw.userAgent),
|
|
118
|
+
logFile: stringValue(path, "logFile", raw.logFile),
|
|
119
|
+
updateYtDlp: booleanValue(path, "updateYtDlp", raw.updateYtDlp),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function defaultConfigPath() {
|
|
123
|
+
if (process.env.YT2TEXT_CONFIG)
|
|
124
|
+
return process.env.YT2TEXT_CONFIG;
|
|
125
|
+
if (process.platform === "win32") {
|
|
126
|
+
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "yt2text", "config.json");
|
|
127
|
+
}
|
|
128
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "yt2text", "config.json");
|
|
129
|
+
}
|
|
130
|
+
export function expandHome(path) {
|
|
131
|
+
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
132
|
+
}
|
|
133
|
+
export async function loadConfig(path = defaultConfigPath()) {
|
|
134
|
+
const target = expandHome(path);
|
|
135
|
+
try {
|
|
136
|
+
const body = await readFile(target, "utf8");
|
|
137
|
+
const parsed = JSON.parse(body);
|
|
138
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
139
|
+
throw new Yt2TextError(`Invalid config ${target}: root must be a JSON object`, "INVALID_CONFIG");
|
|
140
|
+
}
|
|
141
|
+
return normalizeConfig(parsed, target);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error.code === "ENOENT")
|
|
145
|
+
return {};
|
|
146
|
+
if (error instanceof SyntaxError) {
|
|
147
|
+
throw new Yt2TextError(`Invalid config ${target}: ${error.message}`, "INVALID_CONFIG");
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function defaultConfig() {
|
|
153
|
+
return {
|
|
154
|
+
asr: defaultAsrProvider(),
|
|
155
|
+
systemMode: defaultSystemMode(),
|
|
156
|
+
fallback: "fail",
|
|
157
|
+
language: "zh-CN",
|
|
158
|
+
outputDir: "~/Downloads",
|
|
159
|
+
format: "txt",
|
|
160
|
+
cookiesFromBrowser: process.platform === "darwin" ? "auto" : undefined,
|
|
161
|
+
browserCookies: true,
|
|
162
|
+
model: "small",
|
|
163
|
+
offline: false,
|
|
164
|
+
diarize: false,
|
|
165
|
+
chunkSeconds: 55,
|
|
166
|
+
filenameTemplate: "{title}",
|
|
167
|
+
audioQuality: "best",
|
|
168
|
+
keepAudio: false,
|
|
169
|
+
keepOriginalAudio: false,
|
|
170
|
+
parallelDownloads: 4,
|
|
171
|
+
concurrentFragments: 4,
|
|
172
|
+
retries: 10,
|
|
173
|
+
fragmentRetries: 10,
|
|
174
|
+
socketTimeout: 20,
|
|
175
|
+
downloadTimeoutSeconds: 1800,
|
|
176
|
+
convertTimeoutSeconds: 600,
|
|
177
|
+
asrChunkTimeoutSeconds: 180,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export async function writeDefaultConfig(path = defaultConfigPath()) {
|
|
181
|
+
const target = expandHome(path);
|
|
182
|
+
await mkdir(dirname(target), { recursive: true });
|
|
183
|
+
await writeFile(target, `${JSON.stringify(defaultConfig(), null, 2)}\n`, "utf8");
|
|
184
|
+
return target;
|
|
185
|
+
}
|