@mkterswingman/5mghost-yonder 0.0.37 → 0.0.39
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/dist/cli/check.js +6 -2
- package/dist/cli/index.js +14 -1
- package/dist/cli/installSkills.js +21 -12
- package/dist/cli/serve.js +7 -3
- package/dist/cli/setup.js +50 -32
- package/dist/cli/setupCookies.d.ts +1 -4
- package/dist/cli/setupCookies.js +15 -2
- package/dist/cli/uninstall.js +21 -18
- package/dist/contracts/youtubeToolContracts.d.ts +17 -0
- package/dist/contracts/youtubeToolContracts.js +189 -0
- package/dist/server.d.ts +1 -1
- package/dist/tools/downloads.d.ts +1 -1
- package/dist/tools/remote.d.ts +1 -1
- package/dist/tools/remote.js +32 -51
- package/dist/tools/subtitles/cookieSession.d.ts +22 -0
- package/dist/tools/subtitles/cookieSession.js +66 -0
- package/dist/tools/subtitles/download.d.ts +24 -0
- package/dist/tools/subtitles/download.js +169 -0
- package/dist/tools/subtitles/parse.d.ts +1 -0
- package/dist/tools/subtitles/parse.js +106 -0
- package/dist/tools/subtitles.d.ts +5 -26
- package/dist/tools/subtitles.js +7 -389
- package/dist/utils/codeBuddy.d.ts +8 -0
- package/dist/utils/codeBuddy.js +62 -0
- package/dist/utils/cookieRefresh.js +7 -0
- package/dist/utils/launcher.d.ts +6 -11
- package/dist/utils/launcher.js +11 -82
- package/dist/utils/workBuddy.d.ts +8 -0
- package/dist/utils/workBuddy.js +62 -0
- package/package.json +6 -1
- package/dist/auth/oauthFlow.d.ts +0 -9
- package/dist/auth/oauthFlow.js +0 -151
- package/dist/auth/sharedAuth.d.ts +0 -10
- package/dist/auth/sharedAuth.js +0 -31
- package/dist/auth/tokenManager.d.ts +0 -18
- package/dist/auth/tokenManager.js +0 -92
- package/dist/utils/codexInternal.d.ts +0 -9
- package/dist/utils/codexInternal.js +0 -60
- package/dist/utils/mcpRegistration.d.ts +0 -7
- package/dist/utils/mcpRegistration.js +0 -23
- package/dist/utils/openClaw.d.ts +0 -18
- package/dist/utils/openClaw.js +0 -82
- package/dist/utils/skills.d.ts +0 -16
- package/dist/utils/skills.js +0 -56
package/dist/tools/subtitles.js
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
|
-
import { existsSync
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { PATHS } from "../utils/config.js";
|
|
5
5
|
import { runYtDlp } from "../utils/ytdlp.js";
|
|
6
|
-
import { hasSIDCookies
|
|
7
|
-
import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
|
|
8
|
-
import { appendDiagnosticLog, classifyYtDlpFailure } from "../utils/ytdlpFailures.js";
|
|
6
|
+
import { hasSIDCookies } from "../utils/cookies.js";
|
|
9
7
|
import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
|
|
8
|
+
import { COOKIE_EXPIRED_MESSAGE, COOKIE_INVALID_MESSAGE, RATE_LIMITED_MESSAGE, SIGN_IN_REQUIRED_MESSAGE, ensureSubtitleCookiesReady, tryRefreshSubtitleCookies, toReadableSubtitleJobError } from "./subtitles/cookieSession.js";
|
|
9
|
+
import { downloadSubtitle, downloadSubtitlesForLanguages } from "./subtitles/download.js";
|
|
10
|
+
import { vttToCsv } from "./subtitles/parse.js";
|
|
10
11
|
const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/5mghost-yonder setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
|
|
11
|
-
const COOKIE_SETUP_COMMAND = "yt-mcp setup-cookies";
|
|
12
|
-
const COOKIE_MISSING_MESSAGE = `No valid YouTube cookies found.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
13
|
-
const COOKIE_EXPIRED_MESSAGE = `YouTube cookies have expired and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
14
|
-
const COOKIE_INVALID_MESSAGE = `YouTube rejected the current cookies and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
15
|
-
const SIGN_IN_REQUIRED_MESSAGE = `YouTube requested an additional sign-in confirmation and auto-refresh failed.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
16
|
-
const RATE_LIMITED_MESSAGE = "The current YouTube session has been rate-limited. Wait and retry later.";
|
|
17
|
-
const COOKIE_JOB_MESSAGE = `YouTube cookies are missing, expired, or invalid.\nPlease run in your terminal: ${COOKIE_SETUP_COMMAND}`;
|
|
18
12
|
function toolOk(payload) {
|
|
19
13
|
return {
|
|
20
14
|
structuredContent: payload,
|
|
@@ -40,382 +34,14 @@ function sleep(ms) {
|
|
|
40
34
|
function randomSleep(min, max) {
|
|
41
35
|
return sleep(Math.random() * (max - min) + min);
|
|
42
36
|
}
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
return await tryHeadlessRefresh();
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function isCookieFailureText(error) {
|
|
52
|
-
if (!error) {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
const normalized = error.toLowerCase();
|
|
56
|
-
return (normalized.includes("cookies_expired") ||
|
|
57
|
-
normalized.includes("cookie") ||
|
|
58
|
-
normalized.includes("sign in") ||
|
|
59
|
-
normalized.includes("login") ||
|
|
60
|
-
normalized.includes("not a bot"));
|
|
61
|
-
}
|
|
62
|
-
function classifyYtDlpCookieFailure(stderr) {
|
|
63
|
-
const kind = classifyYtDlpFailure(stderr);
|
|
64
|
-
switch (kind) {
|
|
65
|
-
case "COOKIES_EXPIRED":
|
|
66
|
-
return { code: kind, message: COOKIE_EXPIRED_MESSAGE };
|
|
67
|
-
case "COOKIES_INVALID":
|
|
68
|
-
return { code: kind, message: COOKIE_INVALID_MESSAGE };
|
|
69
|
-
case "SIGN_IN_REQUIRED":
|
|
70
|
-
return { code: kind, message: SIGN_IN_REQUIRED_MESSAGE };
|
|
71
|
-
case "RATE_LIMITED":
|
|
72
|
-
return { code: kind, message: RATE_LIMITED_MESSAGE };
|
|
73
|
-
default:
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
export function toReadableSubtitleJobError(error) {
|
|
78
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
-
return isCookieFailureText(message) ? COOKIE_JOB_MESSAGE : message;
|
|
80
|
-
}
|
|
81
|
-
async function ensureSubtitleCookiesReady() {
|
|
82
|
-
const hasSession = hasSIDCookies(PATHS.cookiesTxt);
|
|
83
|
-
const expired = areCookiesExpired(PATHS.cookiesTxt);
|
|
84
|
-
if (hasSession && !expired) {
|
|
85
|
-
return { ok: true };
|
|
86
|
-
}
|
|
87
|
-
const refreshed = await tryRefreshSubtitleCookies();
|
|
88
|
-
if (refreshed && hasSIDCookies(PATHS.cookiesTxt) && !areCookiesExpired(PATHS.cookiesTxt)) {
|
|
89
|
-
return { ok: true };
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
ok: false,
|
|
93
|
-
code: hasSession && expired ? "COOKIES_EXPIRED" : "COOKIES_MISSING",
|
|
94
|
-
toolMessage: hasSession && expired ? COOKIE_EXPIRED_MESSAGE : COOKIE_MISSING_MESSAGE,
|
|
95
|
-
jobMessage: appendDiagnosticLog(COOKIE_JOB_MESSAGE, undefined),
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Decode common HTML entities found in YouTube auto-captions.
|
|
100
|
-
*/
|
|
101
|
-
function decodeHtmlEntities(text) {
|
|
102
|
-
return text
|
|
103
|
-
.replace(/>/g, ">")
|
|
104
|
-
.replace(/</g, "<")
|
|
105
|
-
.replace(/&/g, "&")
|
|
106
|
-
.replace(/"/g, '"')
|
|
107
|
-
.replace(/'/g, "'")
|
|
108
|
-
.replace(/ /g, " ");
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Parse a VTT timestamp line into start/end seconds + clean time strings.
|
|
112
|
-
* Input: "00:00:02.159 --> 00:00:03.590 align:start position:0%"
|
|
113
|
-
* Returns: { startStr, endStr, startSec, endSec } or null if unparseable.
|
|
114
|
-
*/
|
|
115
|
-
function parseTimestamp(line) {
|
|
116
|
-
// Strip positioning metadata (align:start position:0% etc.)
|
|
117
|
-
const match = line.match(/(\d{1,2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}\.\d{3})/);
|
|
118
|
-
if (!match)
|
|
119
|
-
return null;
|
|
120
|
-
const toSec = (t) => {
|
|
121
|
-
const parts = t.split(":");
|
|
122
|
-
return (Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2]));
|
|
123
|
-
};
|
|
124
|
-
return {
|
|
125
|
-
startStr: match[1],
|
|
126
|
-
endStr: match[2],
|
|
127
|
-
startSec: toSec(match[1]),
|
|
128
|
-
endSec: toSec(match[2]),
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Escape a value for CSV (RFC 4180).
|
|
133
|
-
*/
|
|
134
|
-
function csvEscapeField(value) {
|
|
135
|
-
if (/[",\n\r]/.test(value)) {
|
|
136
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
137
|
-
}
|
|
138
|
-
return value;
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Convert VTT subtitle content to clean, human-readable CSV.
|
|
142
|
-
*
|
|
143
|
-
* YouTube auto-captions use a "rolling" VTT format where each cue has two
|
|
144
|
-
* lines: the first line repeats the previous cue's text, and the second line
|
|
145
|
-
* contains new words (marked with <c> tags for word-level timing). This
|
|
146
|
-
* function detects and handles this pattern:
|
|
147
|
-
*
|
|
148
|
-
* 1. Detects auto-caption format (presence of <c> word-timing tags)
|
|
149
|
-
* 2. For auto-captions: extracts only the NEW text from each cue's second
|
|
150
|
-
* line, skips transition cues, and concatenates into clean sentences
|
|
151
|
-
* 3. For manual subtitles: passes through cleanly with no data loss
|
|
152
|
-
* 4. Outputs: start_time, end_time, text
|
|
153
|
-
*/
|
|
154
|
-
export function vttToCsv(vtt) {
|
|
155
|
-
const lines = vtt.split("\n");
|
|
156
|
-
const isAutoCaption = /<\d{2}:\d{2}:\d{2}\.\d{3}><c>/.test(vtt);
|
|
157
|
-
const rawCues = [];
|
|
158
|
-
let currentTs = null;
|
|
159
|
-
let currentTextLines = [];
|
|
160
|
-
for (const line of lines) {
|
|
161
|
-
const trimmed = line.trim();
|
|
162
|
-
if (trimmed.includes(" --> ")) {
|
|
163
|
-
// Flush previous cue
|
|
164
|
-
if (currentTs && currentTextLines.length > 0) {
|
|
165
|
-
let text;
|
|
166
|
-
if (isAutoCaption && currentTextLines.length >= 2) {
|
|
167
|
-
// Auto-caption: line 1 = repeated text, line 2 = new text with <c> tags
|
|
168
|
-
// Only take line 2 (new content)
|
|
169
|
-
text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
|
|
170
|
-
.replace(/<[^>]*>/g, "")
|
|
171
|
-
.trim());
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
// Manual subtitle or single-line cue: take all lines
|
|
175
|
-
text = decodeHtmlEntities(currentTextLines
|
|
176
|
-
.map((l) => l.replace(/<[^>]*>/g, "").trim())
|
|
177
|
-
.filter(Boolean)
|
|
178
|
-
.join(" "));
|
|
179
|
-
}
|
|
180
|
-
if (text) {
|
|
181
|
-
rawCues.push({ ...currentTs, text });
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
currentTs = parseTimestamp(trimmed);
|
|
185
|
-
currentTextLines = [];
|
|
186
|
-
}
|
|
187
|
-
else if (trimmed &&
|
|
188
|
-
!trimmed.startsWith("WEBVTT") &&
|
|
189
|
-
!trimmed.startsWith("Kind:") &&
|
|
190
|
-
!trimmed.startsWith("Language:") &&
|
|
191
|
-
!/^\d+$/.test(trimmed)) {
|
|
192
|
-
currentTextLines.push(trimmed);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// Flush last
|
|
196
|
-
if (currentTs && currentTextLines.length > 0) {
|
|
197
|
-
let text;
|
|
198
|
-
if (isAutoCaption && currentTextLines.length >= 2) {
|
|
199
|
-
text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
|
|
200
|
-
.replace(/<[^>]*>/g, "")
|
|
201
|
-
.trim());
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
text = decodeHtmlEntities(currentTextLines
|
|
205
|
-
.map((l) => l.replace(/<[^>]*>/g, "").trim())
|
|
206
|
-
.filter(Boolean)
|
|
207
|
-
.join(" "));
|
|
208
|
-
}
|
|
209
|
-
if (text) {
|
|
210
|
-
rawCues.push({ ...currentTs, text });
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (rawCues.length === 0) {
|
|
214
|
-
return "start_time,end_time,text\n";
|
|
215
|
-
}
|
|
216
|
-
// ── Step 2: Deduplicate ───────────────────────────────────────
|
|
217
|
-
const deduped = [];
|
|
218
|
-
for (let i = 0; i < rawCues.length; i++) {
|
|
219
|
-
const cur = rawCues[i];
|
|
220
|
-
// Skip tiny transition cues (duration < 50ms)
|
|
221
|
-
const duration = cur.endSec - cur.startSec;
|
|
222
|
-
if (duration < 0.05)
|
|
223
|
-
continue;
|
|
224
|
-
// Merge with previous if same text
|
|
225
|
-
if (deduped.length > 0 && deduped[deduped.length - 1].text === cur.text) {
|
|
226
|
-
deduped[deduped.length - 1].endSec = cur.endSec;
|
|
227
|
-
deduped[deduped.length - 1].endStr = cur.endStr;
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
deduped.push({ ...cur });
|
|
231
|
-
}
|
|
232
|
-
// ── Step 3: Build CSV ─────────────────────────────────────────
|
|
233
|
-
const csvRows = ["start_time,end_time,text"];
|
|
234
|
-
for (const cue of deduped) {
|
|
235
|
-
csvRows.push(`${cue.startStr},${cue.endStr},${csvEscapeField(cue.text)}`);
|
|
236
|
-
}
|
|
237
|
-
return csvRows.join("\n") + "\n";
|
|
238
|
-
}
|
|
239
|
-
function todayDateStr() {
|
|
240
|
-
const d = new Date();
|
|
241
|
-
const yyyy = d.getFullYear();
|
|
242
|
-
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
243
|
-
const dd = String(d.getDate()).padStart(2, "0");
|
|
244
|
-
return `${yyyy}-${mm}-${dd}`;
|
|
245
|
-
}
|
|
246
|
-
async function downloadSubtitle(videoId, lang, format, options = {}) {
|
|
247
|
-
const outputDir = options.outputDir ?? PATHS.subtitlesDir;
|
|
248
|
-
mkdirSync(outputDir, { recursive: true });
|
|
249
|
-
if (options.targetFile) {
|
|
250
|
-
mkdirSync(dirname(options.targetFile), { recursive: true });
|
|
251
|
-
}
|
|
252
|
-
const outTemplate = join(outputDir, options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`);
|
|
253
|
-
// CSV is not a yt-dlp native format — download as VTT then convert
|
|
254
|
-
const dlFormat = format === "csv" ? "vtt" : format;
|
|
255
|
-
const result = await runYtDlp([
|
|
256
|
-
"--skip-download",
|
|
257
|
-
"-f", "mhtml",
|
|
258
|
-
"--write-sub",
|
|
259
|
-
"--write-auto-sub",
|
|
260
|
-
"--sub-langs",
|
|
261
|
-
lang,
|
|
262
|
-
"--sub-format",
|
|
263
|
-
dlFormat,
|
|
264
|
-
"--output",
|
|
265
|
-
outTemplate,
|
|
266
|
-
options.sourceUrl ?? `https://www.youtube.com/watch?v=${videoId}`,
|
|
267
|
-
]);
|
|
268
|
-
const cookieFailure = result.exitCode !== 0 ? classifyYtDlpCookieFailure(result.stderr) : null;
|
|
269
|
-
if (cookieFailure) {
|
|
270
|
-
return {
|
|
271
|
-
ok: false,
|
|
272
|
-
cookieFailureCode: cookieFailure.code,
|
|
273
|
-
diagnosticLogPath: result.failureLogPath,
|
|
274
|
-
error: appendDiagnosticLog(cookieFailure.message, result.failureLogPath),
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
if (result.exitCode !== 0) {
|
|
278
|
-
return {
|
|
279
|
-
ok: false,
|
|
280
|
-
diagnosticLogPath: result.failureLogPath,
|
|
281
|
-
error: appendDiagnosticLog(result.stderr.slice(0, 500) || `yt-dlp exited with ${result.exitCode}`, result.failureLogPath),
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
// Find the output file - yt-dlp appends lang and format extension.
|
|
285
|
-
// For CSV: yt-dlp downloads as VTT first, so only search for VTT (not stale .csv from previous runs).
|
|
286
|
-
const searchFormat = format === "csv" ? "vtt" : format;
|
|
287
|
-
const possibleExts = [`${lang}.${searchFormat}`, `${lang}.vtt`, `${lang}.srt`, `${lang}.ttml`, `${lang}.srv3`];
|
|
288
|
-
let foundFile;
|
|
289
|
-
for (const ext of possibleExts) {
|
|
290
|
-
const candidate = `${outTemplate}.${ext}`;
|
|
291
|
-
if (existsSync(candidate)) {
|
|
292
|
-
foundFile = candidate;
|
|
293
|
-
break;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
if (!foundFile) {
|
|
297
|
-
// Try auto-generated subtitles which may have slightly different naming
|
|
298
|
-
const dir = outputDir;
|
|
299
|
-
try {
|
|
300
|
-
const files = readdirSync(dir);
|
|
301
|
-
const prefix = options.outputStem ?? `${todayDateStr()}_${videoId}_${lang}`;
|
|
302
|
-
const match = files.find((f) => f.startsWith(prefix));
|
|
303
|
-
if (match) {
|
|
304
|
-
foundFile = join(dir, match);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
// ignore
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
if (!foundFile) {
|
|
312
|
-
return {
|
|
313
|
-
ok: false,
|
|
314
|
-
error: `No subtitle file found for language '${lang}'`,
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
// If CSV format requested, convert VTT to CSV (timestamp, text)
|
|
318
|
-
if (format === "csv") {
|
|
319
|
-
const vttPath = foundFile;
|
|
320
|
-
const vttContent = readFileSync(foundFile, "utf8");
|
|
321
|
-
const csvContent = vttToCsv(vttContent);
|
|
322
|
-
const csvPath = foundFile.replace(/\.vtt$/, ".csv");
|
|
323
|
-
writeFileSync(csvPath, csvContent, "utf8");
|
|
324
|
-
foundFile = csvPath;
|
|
325
|
-
if (vttPath !== csvPath) {
|
|
326
|
-
try {
|
|
327
|
-
unlinkSync(vttPath);
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
// ignore
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
if (options.targetFile && foundFile !== options.targetFile) {
|
|
335
|
-
renameSync(foundFile, options.targetFile);
|
|
336
|
-
foundFile = options.targetFile;
|
|
337
|
-
}
|
|
338
|
-
const stat = statSync(foundFile);
|
|
339
|
-
if (stat.size <= 100 * 1024) {
|
|
340
|
-
const text = readFileSync(foundFile, "utf8");
|
|
341
|
-
return { ok: true, text, filePath: foundFile };
|
|
342
|
-
}
|
|
343
|
-
return { ok: true, filePath: foundFile };
|
|
344
|
-
}
|
|
345
|
-
export async function downloadSubtitlesForLanguages(input) {
|
|
346
|
-
const cookieCheck = await ensureSubtitleCookiesReady();
|
|
347
|
-
if (!cookieCheck.ok) {
|
|
348
|
-
throw new Error(cookieCheck.jobMessage);
|
|
349
|
-
}
|
|
350
|
-
const filesByFormat = {};
|
|
351
|
-
const total = input.formats.length * input.languages.length;
|
|
352
|
-
let completed = 0;
|
|
353
|
-
for (const format of input.formats) {
|
|
354
|
-
filesByFormat[format] = [];
|
|
355
|
-
for (const lang of input.languages) {
|
|
356
|
-
const targetFile = join(input.subtitlesDir, `${lang}.${format}`);
|
|
357
|
-
let result = await downloadSubtitle(input.videoId, lang, format, {
|
|
358
|
-
sourceUrl: input.sourceUrl,
|
|
359
|
-
outputDir: input.subtitlesDir,
|
|
360
|
-
outputStem: `${lang}`,
|
|
361
|
-
targetFile,
|
|
362
|
-
});
|
|
363
|
-
if (result.cookieFailureCode && result.cookieFailureCode !== "RATE_LIMITED") {
|
|
364
|
-
const refreshed = await tryRefreshSubtitleCookies();
|
|
365
|
-
if (refreshed) {
|
|
366
|
-
result = await downloadSubtitle(input.videoId, lang, format, {
|
|
367
|
-
sourceUrl: input.sourceUrl,
|
|
368
|
-
outputDir: input.subtitlesDir,
|
|
369
|
-
outputStem: `${lang}`,
|
|
370
|
-
targetFile,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
if (!result.ok || !result.filePath) {
|
|
375
|
-
if (isCookieFailureText(result.error)) {
|
|
376
|
-
throw new Error(COOKIE_JOB_MESSAGE);
|
|
377
|
-
}
|
|
378
|
-
if (input.skipMissingLanguages && isUnavailableSubtitleError(result.error)) {
|
|
379
|
-
completed += 1;
|
|
380
|
-
input.onProgress?.(completed, total);
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
throw new Error(result.error ?? `Failed to download ${format} subtitle for ${lang}`);
|
|
384
|
-
}
|
|
385
|
-
filesByFormat[format].push(result.filePath);
|
|
386
|
-
completed += 1;
|
|
387
|
-
input.onProgress?.(completed, total);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
const hasAnySubtitleFiles = Object.values(filesByFormat).some((files) => files.length > 0);
|
|
391
|
-
if (!hasAnySubtitleFiles) {
|
|
392
|
-
throw new Error(`No subtitles found for requested languages: ${input.languages.join(", ")}`);
|
|
393
|
-
}
|
|
394
|
-
return filesByFormat;
|
|
395
|
-
}
|
|
396
|
-
function isUnavailableSubtitleError(error) {
|
|
397
|
-
if (!error) {
|
|
398
|
-
return false;
|
|
399
|
-
}
|
|
400
|
-
const normalized = error.toLowerCase();
|
|
401
|
-
return (normalized.includes("no subtitle file found") ||
|
|
402
|
-
normalized.includes("no subtitles") ||
|
|
403
|
-
normalized.includes("subtitle is not available") ||
|
|
404
|
-
normalized.includes("requested subtitles are not available"));
|
|
405
|
-
}
|
|
37
|
+
export { vttToCsv, downloadSubtitlesForLanguages, toReadableSubtitleJobError };
|
|
406
38
|
export function registerSubtitleTools(server, config, tokenManager) {
|
|
407
|
-
/**
|
|
408
|
-
* Auth guard — every tool call must pass authentication.
|
|
409
|
-
* Both OAuth (JWT) and PAT modes are supported.
|
|
410
|
-
* Returns error message if not authenticated.
|
|
411
|
-
*/
|
|
412
39
|
async function requireAuth() {
|
|
413
40
|
const token = await tokenManager.getValidToken();
|
|
414
41
|
if (!token)
|
|
415
42
|
return AUTH_REQUIRED_MSG;
|
|
416
43
|
return null;
|
|
417
44
|
}
|
|
418
|
-
// ── get_subtitles ──
|
|
419
45
|
server.registerTool("get_subtitles", {
|
|
420
46
|
description: "Download subtitles for a YouTube video. Accepts video ID or any YouTube URL (watch, shorts, youtu.be). Each language is fetched separately. Supports CSV output (timestamp + text columns). If languages is omitted, defaults to English + Simplified Chinese (unavailable languages are silently skipped).",
|
|
421
47
|
inputSchema: {
|
|
@@ -447,7 +73,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
447
73
|
if (dl.cookieFailureCode) {
|
|
448
74
|
const refreshed = await tryRefreshSubtitleCookies();
|
|
449
75
|
if (refreshed) {
|
|
450
|
-
// Retry the download with fresh cookies
|
|
451
76
|
const retry = await downloadSubtitle(videoId, lang, fmt);
|
|
452
77
|
if (retry.ok) {
|
|
453
78
|
if (retry.text) {
|
|
@@ -458,7 +83,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
458
83
|
}
|
|
459
84
|
continue;
|
|
460
85
|
}
|
|
461
|
-
// Retry also failed with expired cookies — give up
|
|
462
86
|
}
|
|
463
87
|
return toolErr(dl.cookieFailureCode, dl.error ??
|
|
464
88
|
(dl.cookieFailureCode === "COOKIES_EXPIRED"
|
|
@@ -470,7 +94,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
470
94
|
: COOKIE_INVALID_MESSAGE));
|
|
471
95
|
}
|
|
472
96
|
if (!dl.ok) {
|
|
473
|
-
// When using default languages, silently skip unavailable ones
|
|
474
97
|
if (!usingDefaults) {
|
|
475
98
|
results.push({
|
|
476
99
|
language: lang,
|
|
@@ -505,7 +128,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
505
128
|
results,
|
|
506
129
|
});
|
|
507
130
|
});
|
|
508
|
-
// ── batch_get_subtitles ──
|
|
509
131
|
server.registerTool("batch_get_subtitles", {
|
|
510
132
|
description: "Download subtitles for multiple YouTube videos. Max 10 per batch. Accepts video IDs or URLs (can mix).",
|
|
511
133
|
inputSchema: {
|
|
@@ -560,7 +182,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
560
182
|
if (cookieFailureCode) {
|
|
561
183
|
const refreshed = await tryRefreshSubtitleCookies();
|
|
562
184
|
if (refreshed) {
|
|
563
|
-
// Retry this video's subtitles
|
|
564
185
|
let retrySuccess = false;
|
|
565
186
|
for (const lang of langs) {
|
|
566
187
|
const retry = await downloadSubtitle(videoId, lang, fmt);
|
|
@@ -608,7 +229,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
608
229
|
results,
|
|
609
230
|
});
|
|
610
231
|
});
|
|
611
|
-
// ── list_available_subtitles ──
|
|
612
232
|
server.registerTool("list_available_subtitles", {
|
|
613
233
|
description: "List available subtitle tracks for a YouTube video. Accepts video ID or URL.",
|
|
614
234
|
inputSchema: {
|
|
@@ -664,7 +284,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
664
284
|
translatable: automatic,
|
|
665
285
|
});
|
|
666
286
|
});
|
|
667
|
-
// ── validate_cookies ──
|
|
668
287
|
server.registerTool("validate_cookies", {
|
|
669
288
|
description: "Validate current YouTube cookies by testing a subtitle download.",
|
|
670
289
|
inputSchema: {},
|
|
@@ -684,7 +303,6 @@ export function registerSubtitleTools(server, config, tokenManager) {
|
|
|
684
303
|
error: "cookies.txt does not contain YouTube SID cookies",
|
|
685
304
|
});
|
|
686
305
|
}
|
|
687
|
-
// Test with a known public video (Rick Astley - Never Gonna Give You Up)
|
|
688
306
|
const result = await runYtDlp([
|
|
689
307
|
"--skip-download",
|
|
690
308
|
"-f", "mhtml",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function getCodeBuddyConfigPath(homeDir?: string): string;
|
|
2
|
+
export declare function getCodeBuddySkillsDir(homeDir?: string): string;
|
|
3
|
+
export declare function isCodeBuddyInstallLikelyInstalled(configPath?: string): boolean;
|
|
4
|
+
export declare function writeCodeBuddyConfig(serverName: string, launcherCommand: {
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
}, configPath?: string): "created" | "updated";
|
|
8
|
+
export declare function removeCodeBuddyConfig(serverName: string, configPath?: string): "removed" | "missing";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function getCodeBuddyConfigPath(homeDir = homedir()) {
|
|
5
|
+
return join(homeDir, ".codebuddy", ".mcp.json");
|
|
6
|
+
}
|
|
7
|
+
export function getCodeBuddySkillsDir(homeDir = homedir()) {
|
|
8
|
+
return join(homeDir, ".codebuddy", "skills");
|
|
9
|
+
}
|
|
10
|
+
function parseConfig(raw) {
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|
|
15
|
+
throw new Error("CodeBuddy config must be a JSON object");
|
|
16
|
+
}
|
|
17
|
+
export function isCodeBuddyInstallLikelyInstalled(configPath = getCodeBuddyConfigPath()) {
|
|
18
|
+
return existsSync(configPath) || existsSync(dirname(configPath));
|
|
19
|
+
}
|
|
20
|
+
export function writeCodeBuddyConfig(serverName, launcherCommand, configPath = getCodeBuddyConfigPath()) {
|
|
21
|
+
const existingText = existsSync(configPath) ? readFileSync(configPath, "utf8") : null;
|
|
22
|
+
const created = existingText === null;
|
|
23
|
+
const config = existingText ? parseConfig(existingText) : {};
|
|
24
|
+
const mcpServers = config.mcpServers && typeof config.mcpServers === "object"
|
|
25
|
+
? { ...config.mcpServers }
|
|
26
|
+
: {};
|
|
27
|
+
mcpServers[serverName] = {
|
|
28
|
+
command: launcherCommand.command,
|
|
29
|
+
args: [...launcherCommand.args],
|
|
30
|
+
};
|
|
31
|
+
const nextConfig = {
|
|
32
|
+
...config,
|
|
33
|
+
mcpServers,
|
|
34
|
+
};
|
|
35
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
36
|
+
writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
|
|
37
|
+
return created ? "created" : "updated";
|
|
38
|
+
}
|
|
39
|
+
export function removeCodeBuddyConfig(serverName, configPath = getCodeBuddyConfigPath()) {
|
|
40
|
+
if (!existsSync(configPath)) {
|
|
41
|
+
return "missing";
|
|
42
|
+
}
|
|
43
|
+
const config = parseConfig(readFileSync(configPath, "utf8"));
|
|
44
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object" || !(serverName in config.mcpServers)) {
|
|
45
|
+
return "missing";
|
|
46
|
+
}
|
|
47
|
+
const nextMcpServers = { ...config.mcpServers };
|
|
48
|
+
delete nextMcpServers[serverName];
|
|
49
|
+
const nextConfig = { ...config };
|
|
50
|
+
if (Object.keys(nextMcpServers).length === 0) {
|
|
51
|
+
delete nextConfig.mcpServers;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
nextConfig.mcpServers = nextMcpServers;
|
|
55
|
+
}
|
|
56
|
+
if (Object.keys(nextConfig).length === 0) {
|
|
57
|
+
rmSync(configPath, { force: true });
|
|
58
|
+
return "removed";
|
|
59
|
+
}
|
|
60
|
+
writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
|
|
61
|
+
return "removed";
|
|
62
|
+
}
|
|
@@ -13,6 +13,9 @@ import { PATHS, ensureConfigDir } from "./config.js";
|
|
|
13
13
|
import { detectBrowserChannel, hasYouTubeSession, saveCookiesToDisk, } from "../cli/setupCookies.js";
|
|
14
14
|
import { hasSIDCookies } from "./cookies.js";
|
|
15
15
|
const HEADLESS_TIMEOUT_MS = 30_000;
|
|
16
|
+
function hasInteractiveTerminal() {
|
|
17
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
18
|
+
}
|
|
16
19
|
/**
|
|
17
20
|
* Attempt to refresh YouTube cookies through the dedicated two-tier fallback chain.
|
|
18
21
|
*
|
|
@@ -26,6 +29,10 @@ export async function tryHeadlessRefresh() {
|
|
|
26
29
|
if (tier1)
|
|
27
30
|
return true;
|
|
28
31
|
}
|
|
32
|
+
// Why: MCP stdio calls and tests run without a TTY; opening manual browser login would block until timeout.
|
|
33
|
+
if (!hasInteractiveTerminal()) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
29
36
|
// Tier 2: Open headed browser for manual login
|
|
30
37
|
return tryHeadedManualLogin();
|
|
31
38
|
}
|
package/dist/utils/launcher.d.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
}
|
|
5
|
-
export interface LauncherCommand {
|
|
6
|
-
command: string;
|
|
7
|
-
args: string[];
|
|
8
|
-
}
|
|
1
|
+
import { isRepairableNpxFailure } from "@mkterswingman/5mghost-shared-client/launcher";
|
|
2
|
+
import type { LauncherCommand, LauncherSourceOptions } from "@mkterswingman/5mghost-shared-client/launcher";
|
|
3
|
+
export type { LauncherCommand, LauncherSourceOptions };
|
|
4
|
+
export { isRepairableNpxFailure };
|
|
9
5
|
export declare function buildLauncherCommand(launcherPath?: string): LauncherCommand;
|
|
10
|
-
export declare function
|
|
11
|
-
export declare function
|
|
12
|
-
export declare function writeLauncherFile(options?: LauncherSourceOptions): string;
|
|
6
|
+
export declare function buildLauncherSource(options?: Partial<LauncherSourceOptions>): string;
|
|
7
|
+
export declare function writeLauncherFile(options?: Partial<LauncherSourceOptions>): string;
|