@mkterswingman/5mghost-yonder 0.0.26 → 0.0.27

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.
Files changed (93) hide show
  1. package/package.json +1 -1
  2. package/dist/auth/oauthFlow.d.ts +0 -9
  3. package/dist/auth/oauthFlow.js +0 -151
  4. package/dist/auth/sharedAuth.d.ts +0 -10
  5. package/dist/auth/sharedAuth.js +0 -31
  6. package/dist/auth/tokenManager.d.ts +0 -18
  7. package/dist/auth/tokenManager.js +0 -92
  8. package/dist/cli/check.d.ts +0 -4
  9. package/dist/cli/check.js +0 -90
  10. package/dist/cli/index.d.ts +0 -18
  11. package/dist/cli/index.js +0 -166
  12. package/dist/cli/installSkills.d.ts +0 -1
  13. package/dist/cli/installSkills.js +0 -39
  14. package/dist/cli/runtime.d.ts +0 -9
  15. package/dist/cli/runtime.js +0 -35
  16. package/dist/cli/serve.d.ts +0 -1
  17. package/dist/cli/serve.js +0 -26
  18. package/dist/cli/setup.d.ts +0 -33
  19. package/dist/cli/setup.js +0 -450
  20. package/dist/cli/setupCookies.d.ts +0 -88
  21. package/dist/cli/setupCookies.js +0 -431
  22. package/dist/cli/smoke.d.ts +0 -27
  23. package/dist/cli/smoke.js +0 -108
  24. package/dist/cli/uninstall.d.ts +0 -16
  25. package/dist/cli/uninstall.js +0 -99
  26. package/dist/download/downloader.d.ts +0 -67
  27. package/dist/download/downloader.js +0 -309
  28. package/dist/download/jobManager.d.ts +0 -22
  29. package/dist/download/jobManager.js +0 -211
  30. package/dist/download/types.d.ts +0 -44
  31. package/dist/download/types.js +0 -1
  32. package/dist/runtime/ffmpegRuntime.d.ts +0 -13
  33. package/dist/runtime/ffmpegRuntime.js +0 -51
  34. package/dist/runtime/installers.d.ts +0 -12
  35. package/dist/runtime/installers.js +0 -45
  36. package/dist/runtime/manifest.d.ts +0 -18
  37. package/dist/runtime/manifest.js +0 -43
  38. package/dist/runtime/playwrightRuntime.d.ts +0 -17
  39. package/dist/runtime/playwrightRuntime.js +0 -49
  40. package/dist/runtime/systemDeps.d.ts +0 -3
  41. package/dist/runtime/systemDeps.js +0 -30
  42. package/dist/runtime/ytdlpRuntime.d.ts +0 -14
  43. package/dist/runtime/ytdlpRuntime.js +0 -58
  44. package/dist/server.d.ts +0 -23
  45. package/dist/server.js +0 -81
  46. package/dist/tools/downloads.d.ts +0 -11
  47. package/dist/tools/downloads.js +0 -220
  48. package/dist/tools/remote.d.ts +0 -4
  49. package/dist/tools/remote.js +0 -239
  50. package/dist/tools/subtitles.d.ts +0 -29
  51. package/dist/tools/subtitles.js +0 -713
  52. package/dist/utils/browserLaunch.d.ts +0 -5
  53. package/dist/utils/browserLaunch.js +0 -22
  54. package/dist/utils/browserProfileImport.d.ts +0 -49
  55. package/dist/utils/browserProfileImport.js +0 -163
  56. package/dist/utils/codexInternal.d.ts +0 -9
  57. package/dist/utils/codexInternal.js +0 -60
  58. package/dist/utils/config.d.ts +0 -53
  59. package/dist/utils/config.js +0 -77
  60. package/dist/utils/cookieRefresh.d.ts +0 -18
  61. package/dist/utils/cookieRefresh.js +0 -70
  62. package/dist/utils/cookies.d.ts +0 -18
  63. package/dist/utils/cookies.js +0 -100
  64. package/dist/utils/ffmpeg.d.ts +0 -5
  65. package/dist/utils/ffmpeg.js +0 -16
  66. package/dist/utils/ffmpegPath.d.ts +0 -8
  67. package/dist/utils/ffmpegPath.js +0 -21
  68. package/dist/utils/formatters.d.ts +0 -4
  69. package/dist/utils/formatters.js +0 -42
  70. package/dist/utils/launcher.d.ts +0 -12
  71. package/dist/utils/launcher.js +0 -90
  72. package/dist/utils/mcpRegistration.d.ts +0 -7
  73. package/dist/utils/mcpRegistration.js +0 -23
  74. package/dist/utils/mediaPaths.d.ts +0 -7
  75. package/dist/utils/mediaPaths.js +0 -10
  76. package/dist/utils/openClaw.d.ts +0 -18
  77. package/dist/utils/openClaw.js +0 -82
  78. package/dist/utils/skills.d.ts +0 -16
  79. package/dist/utils/skills.js +0 -56
  80. package/dist/utils/videoInput.d.ts +0 -5
  81. package/dist/utils/videoInput.js +0 -58
  82. package/dist/utils/videoMetadata.d.ts +0 -11
  83. package/dist/utils/videoMetadata.js +0 -1
  84. package/dist/utils/ytdlp.d.ts +0 -28
  85. package/dist/utils/ytdlp.js +0 -205
  86. package/dist/utils/ytdlpFailures.d.ts +0 -3
  87. package/dist/utils/ytdlpFailures.js +0 -27
  88. package/dist/utils/ytdlpPath.d.ts +0 -33
  89. package/dist/utils/ytdlpPath.js +0 -77
  90. package/dist/utils/ytdlpProgress.d.ts +0 -13
  91. package/dist/utils/ytdlpProgress.js +0 -77
  92. package/dist/utils/ytdlpScheduler.d.ts +0 -25
  93. package/dist/utils/ytdlpScheduler.js +0 -78
@@ -1,713 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { z } from "zod";
4
- import { PATHS } from "../utils/config.js";
5
- import { runYtDlp } from "../utils/ytdlp.js";
6
- import { hasSIDCookies, areCookiesExpired } from "../utils/cookies.js";
7
- import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
8
- import { appendDiagnosticLog, classifyYtDlpFailure } from "../utils/ytdlpFailures.js";
9
- import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
10
- 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
- function toolOk(payload) {
19
- return {
20
- structuredContent: payload,
21
- content: [
22
- {
23
- type: "text",
24
- text: JSON.stringify(payload),
25
- },
26
- ],
27
- };
28
- }
29
- function toolErr(code, message) {
30
- const payload = { status: "failed", error: { code, message } };
31
- return {
32
- structuredContent: payload,
33
- isError: true,
34
- content: [{ type: "text", text: JSON.stringify(payload) }],
35
- };
36
- }
37
- function sleep(ms) {
38
- return new Promise((r) => setTimeout(r, ms));
39
- }
40
- function randomSleep(min, max) {
41
- return sleep(Math.random() * (max - min) + min);
42
- }
43
- async function tryRefreshSubtitleCookies() {
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(/&lt;/g, "<")
105
- .replace(/&amp;/g, "&")
106
- .replace(/&quot;/g, '"')
107
- .replace(/&#39;/g, "'")
108
- .replace(/&nbsp;/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
- }
406
- 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
- async function requireAuth() {
413
- const token = await tokenManager.getValidToken();
414
- if (!token)
415
- return AUTH_REQUIRED_MSG;
416
- return null;
417
- }
418
- // ── get_subtitles ──
419
- server.registerTool("get_subtitles", {
420
- 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
- inputSchema: {
422
- video: z.string().min(1).describe("YouTube video ID or URL"),
423
- languages: z.array(z.string().min(1)).optional(),
424
- format: z.enum(["vtt", "srt", "ttml", "srv3", "csv"]).optional(),
425
- },
426
- }, async ({ video, languages, format }) => {
427
- const authErr = await requireAuth();
428
- if (authErr)
429
- return toolErr("AUTH_REQUIRED", authErr);
430
- const videoId = resolveVideoInput(video);
431
- if (!videoId)
432
- return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
433
- const cookieCheck = await ensureSubtitleCookiesReady();
434
- if (!cookieCheck.ok) {
435
- return toolErr(cookieCheck.code, cookieCheck.toolMessage);
436
- }
437
- const usingDefaults = !languages;
438
- const langs = languages ?? config.default_languages;
439
- const fmt = format ?? "vtt";
440
- const results = [];
441
- for (let i = 0; i < langs.length; i++) {
442
- if (i > 0) {
443
- await randomSleep(2000, 5000);
444
- }
445
- const lang = langs[i];
446
- const dl = await downloadSubtitle(videoId, lang, fmt);
447
- if (dl.cookieFailureCode) {
448
- const refreshed = await tryRefreshSubtitleCookies();
449
- if (refreshed) {
450
- // Retry the download with fresh cookies
451
- const retry = await downloadSubtitle(videoId, lang, fmt);
452
- if (retry.ok) {
453
- if (retry.text) {
454
- results.push({ language: lang, status: "ok", text: retry.text, file_path: retry.filePath });
455
- }
456
- else {
457
- results.push({ language: lang, status: "ok", file_path: retry.filePath });
458
- }
459
- continue;
460
- }
461
- // Retry also failed with expired cookies — give up
462
- }
463
- return toolErr(dl.cookieFailureCode, dl.error ??
464
- (dl.cookieFailureCode === "COOKIES_EXPIRED"
465
- ? COOKIE_EXPIRED_MESSAGE
466
- : dl.cookieFailureCode === "SIGN_IN_REQUIRED"
467
- ? SIGN_IN_REQUIRED_MESSAGE
468
- : dl.cookieFailureCode === "RATE_LIMITED"
469
- ? RATE_LIMITED_MESSAGE
470
- : COOKIE_INVALID_MESSAGE));
471
- }
472
- if (!dl.ok) {
473
- // When using default languages, silently skip unavailable ones
474
- if (!usingDefaults) {
475
- results.push({
476
- language: lang,
477
- status: "failed",
478
- error: dl.error,
479
- });
480
- }
481
- }
482
- else if (dl.text) {
483
- results.push({
484
- language: lang,
485
- status: "ok",
486
- text: dl.text,
487
- file_path: dl.filePath,
488
- });
489
- }
490
- else {
491
- results.push({
492
- language: lang,
493
- status: "ok",
494
- file_path: dl.filePath,
495
- });
496
- }
497
- }
498
- if (results.length === 0) {
499
- return toolErr("NO_SUBTITLES", `No subtitles found for requested languages: ${langs.join(", ")}`);
500
- }
501
- return toolOk({
502
- status: "completed",
503
- video_id: videoId,
504
- format: fmt,
505
- results,
506
- });
507
- });
508
- // ── batch_get_subtitles ──
509
- server.registerTool("batch_get_subtitles", {
510
- description: "Download subtitles for multiple YouTube videos. Max 10 per batch. Accepts video IDs or URLs (can mix).",
511
- inputSchema: {
512
- videos: z.array(z.string().min(1)).min(1).max(10).describe("Video IDs or YouTube URLs"),
513
- languages: z.array(z.string().min(1)).optional(),
514
- format: z.enum(["vtt", "srt", "ttml", "srv3", "csv"]).optional(),
515
- },
516
- }, async ({ videos, languages, format }) => {
517
- const authErr = await requireAuth();
518
- if (authErr)
519
- return toolErr("AUTH_REQUIRED", authErr);
520
- const { resolvedIds, invalidInputs } = normalizeVideoInputs(videos);
521
- if (resolvedIds.length === 0) {
522
- return toolErr("INVALID_INPUT", `无法解析任何视频 ID。无效输入: ${invalidInputs.join(", ")}`);
523
- }
524
- const langs = languages ?? config.default_languages;
525
- const fmt = format ?? "vtt";
526
- const cookieCheck = await ensureSubtitleCookiesReady();
527
- if (!cookieCheck.ok) {
528
- return toolErr(cookieCheck.code, cookieCheck.toolMessage);
529
- }
530
- const results = [];
531
- let succeeded = 0;
532
- let failed = 0;
533
- for (let v = 0; v < resolvedIds.length; v++) {
534
- if (v > 0) {
535
- await randomSleep(config.batch_sleep_min_ms, config.batch_sleep_max_ms);
536
- }
537
- const videoId = resolvedIds[v];
538
- const langFound = [];
539
- let lastError;
540
- let cookieFailureCode = null;
541
- for (let l = 0; l < langs.length; l++) {
542
- if (l > 0) {
543
- await randomSleep(2000, 5000);
544
- }
545
- const dl = await downloadSubtitle(videoId, langs[l], fmt);
546
- if (dl.cookieFailureCode) {
547
- cookieFailureCode = dl.cookieFailureCode;
548
- break;
549
- }
550
- if (dl.ok) {
551
- langFound.push(langs[l]);
552
- }
553
- else {
554
- lastError = dl.error;
555
- }
556
- }
557
- if (cookieFailureCode === "RATE_LIMITED") {
558
- return toolErr("RATE_LIMITED", RATE_LIMITED_MESSAGE);
559
- }
560
- if (cookieFailureCode) {
561
- const refreshed = await tryRefreshSubtitleCookies();
562
- if (refreshed) {
563
- // Retry this video's subtitles
564
- let retrySuccess = false;
565
- for (const lang of langs) {
566
- const retry = await downloadSubtitle(videoId, lang, fmt);
567
- if (retry.ok) {
568
- langFound.push(lang);
569
- retrySuccess = true;
570
- }
571
- }
572
- if (retrySuccess) {
573
- cookieFailureCode = null;
574
- }
575
- }
576
- if (cookieFailureCode) {
577
- return toolErr(cookieFailureCode, cookieFailureCode === "COOKIES_EXPIRED"
578
- ? COOKIE_EXPIRED_MESSAGE
579
- : cookieFailureCode === "SIGN_IN_REQUIRED"
580
- ? SIGN_IN_REQUIRED_MESSAGE
581
- : COOKIE_INVALID_MESSAGE);
582
- }
583
- }
584
- if (langFound.length > 0) {
585
- succeeded++;
586
- results.push({
587
- video_id: videoId,
588
- status: "ok",
589
- languages_found: langFound,
590
- file_path: PATHS.subtitlesDir,
591
- });
592
- }
593
- else {
594
- failed++;
595
- results.push({
596
- video_id: videoId,
597
- status: "failed",
598
- error: lastError ?? "No subtitles found",
599
- });
600
- }
601
- }
602
- return toolOk({
603
- status: "completed",
604
- total: resolvedIds.length,
605
- succeeded,
606
- failed,
607
- invalid_inputs: invalidInputs.length > 0 ? invalidInputs : undefined,
608
- results,
609
- });
610
- });
611
- // ── list_available_subtitles ──
612
- server.registerTool("list_available_subtitles", {
613
- description: "List available subtitle tracks for a YouTube video. Accepts video ID or URL.",
614
- inputSchema: {
615
- video: z.string().min(1).describe("YouTube video ID or URL"),
616
- },
617
- }, async ({ video }) => {
618
- const authErr = await requireAuth();
619
- if (authErr)
620
- return toolErr("AUTH_REQUIRED", authErr);
621
- const videoId = resolveVideoInput(video);
622
- if (!videoId)
623
- return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
624
- const result = await runYtDlp([
625
- "--list-subs",
626
- "--skip-download",
627
- `https://www.youtube.com/watch?v=${videoId}`,
628
- ]);
629
- if (result.exitCode !== 0) {
630
- return toolErr("YT_DLP_ERROR", result.stderr.slice(0, 500) || "Failed to list subtitles");
631
- }
632
- const output = result.stdout;
633
- const manual = [];
634
- const automatic = [];
635
- let section = "none";
636
- for (const line of output.split("\n")) {
637
- const trimmed = line.trim();
638
- if (trimmed.startsWith("[info] Available subtitles") ||
639
- trimmed.startsWith("Available subtitles")) {
640
- section = "manual";
641
- continue;
642
- }
643
- if (trimmed.startsWith("[info] Available automatic captions") ||
644
- trimmed.startsWith("Available automatic captions")) {
645
- section = "auto";
646
- continue;
647
- }
648
- if (!trimmed || trimmed.startsWith("Language") || trimmed.startsWith("---")) {
649
- continue;
650
- }
651
- const langCode = trimmed.split(/\s+/)[0];
652
- if (!langCode)
653
- continue;
654
- if (section === "manual")
655
- manual.push(langCode);
656
- else if (section === "auto")
657
- automatic.push(langCode);
658
- }
659
- return toolOk({
660
- status: "completed",
661
- video_id: videoId,
662
- manual,
663
- automatic,
664
- translatable: automatic,
665
- });
666
- });
667
- // ── validate_cookies ──
668
- server.registerTool("validate_cookies", {
669
- description: "Validate current YouTube cookies by testing a subtitle download.",
670
- inputSchema: {},
671
- }, async () => {
672
- const authErr = await requireAuth();
673
- if (authErr)
674
- return toolErr("AUTH_REQUIRED", authErr);
675
- if (!existsSync(PATHS.cookiesTxt)) {
676
- return toolOk({
677
- valid: false,
678
- error: "cookies.txt not found",
679
- });
680
- }
681
- if (!hasSIDCookies(PATHS.cookiesTxt)) {
682
- return toolOk({
683
- valid: false,
684
- error: "cookies.txt does not contain YouTube SID cookies",
685
- });
686
- }
687
- // Test with a known public video (Rick Astley - Never Gonna Give You Up)
688
- const result = await runYtDlp([
689
- "--skip-download",
690
- "-f", "mhtml",
691
- "--write-auto-sub",
692
- "--sub-langs",
693
- "en",
694
- "--sub-format",
695
- "vtt",
696
- "--output",
697
- join(PATHS.subtitlesDir, "cookie_test"),
698
- "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
699
- ]);
700
- if (result.exitCode === 0) {
701
- return toolOk({ valid: true });
702
- }
703
- const isExpired = result.stderr.includes("Sign in") ||
704
- result.stderr.includes("cookies") ||
705
- result.stderr.includes("login");
706
- return toolOk({
707
- valid: false,
708
- error: isExpired
709
- ? "Cookies expired"
710
- : result.stderr.slice(0, 300),
711
- });
712
- });
713
- }