@mkterswingman/5mghost-yonder 0.0.1

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.
@@ -0,0 +1,579 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { 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 { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
9
+ const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/5mghost-yonder setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
10
+ function toolOk(payload) {
11
+ return {
12
+ structuredContent: payload,
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: JSON.stringify(payload),
17
+ },
18
+ ],
19
+ };
20
+ }
21
+ function toolErr(code, message) {
22
+ const payload = { status: "failed", error: { code, message } };
23
+ return {
24
+ structuredContent: payload,
25
+ isError: true,
26
+ content: [{ type: "text", text: JSON.stringify(payload) }],
27
+ };
28
+ }
29
+ function sleep(ms) {
30
+ return new Promise((r) => setTimeout(r, ms));
31
+ }
32
+ function randomSleep(min, max) {
33
+ return sleep(Math.random() * (max - min) + min);
34
+ }
35
+ /**
36
+ * Decode common HTML entities found in YouTube auto-captions.
37
+ */
38
+ function decodeHtmlEntities(text) {
39
+ return text
40
+ .replace(/>/g, ">")
41
+ .replace(/&lt;/g, "<")
42
+ .replace(/&amp;/g, "&")
43
+ .replace(/&quot;/g, '"')
44
+ .replace(/&#39;/g, "'")
45
+ .replace(/&nbsp;/g, " ");
46
+ }
47
+ /**
48
+ * Parse a VTT timestamp line into start/end seconds + clean time strings.
49
+ * Input: "00:00:02.159 --> 00:00:03.590 align:start position:0%"
50
+ * Returns: { startStr, endStr, startSec, endSec } or null if unparseable.
51
+ */
52
+ function parseTimestamp(line) {
53
+ // Strip positioning metadata (align:start position:0% etc.)
54
+ const match = line.match(/(\d{1,2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}\.\d{3})/);
55
+ if (!match)
56
+ return null;
57
+ const toSec = (t) => {
58
+ const parts = t.split(":");
59
+ return (Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2]));
60
+ };
61
+ return {
62
+ startStr: match[1],
63
+ endStr: match[2],
64
+ startSec: toSec(match[1]),
65
+ endSec: toSec(match[2]),
66
+ };
67
+ }
68
+ /**
69
+ * Escape a value for CSV (RFC 4180).
70
+ */
71
+ function csvEscapeField(value) {
72
+ if (/[",\n\r]/.test(value)) {
73
+ return `"${value.replace(/"/g, '""')}"`;
74
+ }
75
+ return value;
76
+ }
77
+ /**
78
+ * Convert VTT subtitle content to clean, human-readable CSV.
79
+ *
80
+ * YouTube auto-captions use a "rolling" VTT format where each cue has two
81
+ * lines: the first line repeats the previous cue's text, and the second line
82
+ * contains new words (marked with <c> tags for word-level timing). This
83
+ * function detects and handles this pattern:
84
+ *
85
+ * 1. Detects auto-caption format (presence of <c> word-timing tags)
86
+ * 2. For auto-captions: extracts only the NEW text from each cue's second
87
+ * line, skips transition cues, and concatenates into clean sentences
88
+ * 3. For manual subtitles: passes through cleanly with no data loss
89
+ * 4. Outputs: start_time, end_time, text
90
+ */
91
+ function vttToCsv(vtt) {
92
+ const lines = vtt.split("\n");
93
+ const isAutoCaption = /<\d{2}:\d{2}:\d{2}\.\d{3}><c>/.test(vtt);
94
+ const rawCues = [];
95
+ let currentTs = null;
96
+ let currentTextLines = [];
97
+ for (const line of lines) {
98
+ const trimmed = line.trim();
99
+ if (trimmed.includes(" --> ")) {
100
+ // Flush previous cue
101
+ if (currentTs && currentTextLines.length > 0) {
102
+ let text;
103
+ if (isAutoCaption && currentTextLines.length >= 2) {
104
+ // Auto-caption: line 1 = repeated text, line 2 = new text with <c> tags
105
+ // Only take line 2 (new content)
106
+ text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
107
+ .replace(/<[^>]*>/g, "")
108
+ .trim());
109
+ }
110
+ else {
111
+ // Manual subtitle or single-line cue: take all lines
112
+ text = decodeHtmlEntities(currentTextLines
113
+ .map((l) => l.replace(/<[^>]*>/g, "").trim())
114
+ .filter(Boolean)
115
+ .join(" "));
116
+ }
117
+ if (text) {
118
+ rawCues.push({ ...currentTs, text });
119
+ }
120
+ }
121
+ currentTs = parseTimestamp(trimmed);
122
+ currentTextLines = [];
123
+ }
124
+ else if (trimmed &&
125
+ !trimmed.startsWith("WEBVTT") &&
126
+ !trimmed.startsWith("Kind:") &&
127
+ !trimmed.startsWith("Language:") &&
128
+ !/^\d+$/.test(trimmed)) {
129
+ currentTextLines.push(trimmed);
130
+ }
131
+ }
132
+ // Flush last
133
+ if (currentTs && currentTextLines.length > 0) {
134
+ let text;
135
+ if (isAutoCaption && currentTextLines.length >= 2) {
136
+ text = decodeHtmlEntities(currentTextLines[currentTextLines.length - 1]
137
+ .replace(/<[^>]*>/g, "")
138
+ .trim());
139
+ }
140
+ else {
141
+ text = decodeHtmlEntities(currentTextLines
142
+ .map((l) => l.replace(/<[^>]*>/g, "").trim())
143
+ .filter(Boolean)
144
+ .join(" "));
145
+ }
146
+ if (text) {
147
+ rawCues.push({ ...currentTs, text });
148
+ }
149
+ }
150
+ if (rawCues.length === 0) {
151
+ return "start_time,end_time,text\n";
152
+ }
153
+ // ── Step 2: Deduplicate ───────────────────────────────────────
154
+ const deduped = [];
155
+ for (let i = 0; i < rawCues.length; i++) {
156
+ const cur = rawCues[i];
157
+ // Skip tiny transition cues (duration < 50ms)
158
+ const duration = cur.endSec - cur.startSec;
159
+ if (duration < 0.05)
160
+ continue;
161
+ // Merge with previous if same text
162
+ if (deduped.length > 0 && deduped[deduped.length - 1].text === cur.text) {
163
+ deduped[deduped.length - 1].endSec = cur.endSec;
164
+ deduped[deduped.length - 1].endStr = cur.endStr;
165
+ continue;
166
+ }
167
+ deduped.push({ ...cur });
168
+ }
169
+ // ── Step 3: Build CSV ─────────────────────────────────────────
170
+ const csvRows = ["start_time,end_time,text"];
171
+ for (const cue of deduped) {
172
+ csvRows.push(`${cue.startStr},${cue.endStr},${csvEscapeField(cue.text)}`);
173
+ }
174
+ return csvRows.join("\n") + "\n";
175
+ }
176
+ function todayDateStr() {
177
+ const d = new Date();
178
+ const yyyy = d.getFullYear();
179
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
180
+ const dd = String(d.getDate()).padStart(2, "0");
181
+ return `${yyyy}-${mm}-${dd}`;
182
+ }
183
+ async function downloadSubtitle(videoId, lang, format) {
184
+ mkdirSync(PATHS.subtitlesDir, { recursive: true });
185
+ const outTemplate = join(PATHS.subtitlesDir, `${todayDateStr()}_${videoId}_${lang}`);
186
+ // CSV is not a yt-dlp native format — download as VTT then convert
187
+ const dlFormat = format === "csv" ? "vtt" : format;
188
+ const result = await runYtDlp([
189
+ "--skip-download",
190
+ "-f", "mhtml",
191
+ "--write-sub",
192
+ "--write-auto-sub",
193
+ "--sub-langs",
194
+ lang,
195
+ "--sub-format",
196
+ dlFormat,
197
+ "--output",
198
+ outTemplate,
199
+ `https://www.youtube.com/watch?v=${videoId}`,
200
+ ]);
201
+ if (result.exitCode !== 0 &&
202
+ (result.stderr.includes("Sign in") ||
203
+ result.stderr.includes("cookies") ||
204
+ result.stderr.includes("login"))) {
205
+ return { ok: false, cookiesExpired: true, error: "COOKIES_EXPIRED" };
206
+ }
207
+ if (result.exitCode !== 0) {
208
+ return {
209
+ ok: false,
210
+ error: result.stderr.slice(0, 500) || `yt-dlp exited with ${result.exitCode}`,
211
+ };
212
+ }
213
+ // Find the output file - yt-dlp appends lang and format extension.
214
+ // For CSV: yt-dlp downloads as VTT first, so only search for VTT (not stale .csv from previous runs).
215
+ const searchFormat = format === "csv" ? "vtt" : format;
216
+ const possibleExts = [`${lang}.${searchFormat}`, `${lang}.vtt`, `${lang}.srt`, `${lang}.ttml`, `${lang}.srv3`];
217
+ let foundFile;
218
+ for (const ext of possibleExts) {
219
+ const candidate = `${outTemplate}.${ext}`;
220
+ if (existsSync(candidate)) {
221
+ foundFile = candidate;
222
+ break;
223
+ }
224
+ }
225
+ if (!foundFile) {
226
+ // Try auto-generated subtitles which may have slightly different naming
227
+ const dir = PATHS.subtitlesDir;
228
+ try {
229
+ const files = readdirSync(dir);
230
+ const prefix = `${todayDateStr()}_${videoId}_${lang}`;
231
+ const match = files.find((f) => f.startsWith(prefix));
232
+ if (match) {
233
+ foundFile = join(dir, match);
234
+ }
235
+ }
236
+ catch {
237
+ // ignore
238
+ }
239
+ }
240
+ if (!foundFile) {
241
+ return {
242
+ ok: false,
243
+ error: `No subtitle file found for language '${lang}'`,
244
+ };
245
+ }
246
+ // If CSV format requested, convert VTT to CSV (timestamp, text)
247
+ if (format === "csv") {
248
+ const vttContent = readFileSync(foundFile, "utf8");
249
+ const csvContent = vttToCsv(vttContent);
250
+ const csvPath = foundFile.replace(/\.vtt$/, ".csv");
251
+ writeFileSync(csvPath, csvContent, "utf8");
252
+ foundFile = csvPath;
253
+ }
254
+ const stat = statSync(foundFile);
255
+ if (stat.size <= 100 * 1024) {
256
+ const text = readFileSync(foundFile, "utf8");
257
+ return { ok: true, text, filePath: foundFile };
258
+ }
259
+ return { ok: true, filePath: foundFile };
260
+ }
261
+ export function registerSubtitleTools(server, config, tokenManager) {
262
+ /**
263
+ * Auth guard — every tool call must pass authentication.
264
+ * Both OAuth (JWT) and PAT modes are supported.
265
+ * Returns error message if not authenticated.
266
+ */
267
+ async function requireAuth() {
268
+ const token = await tokenManager.getValidToken();
269
+ if (!token)
270
+ return AUTH_REQUIRED_MSG;
271
+ return null;
272
+ }
273
+ // ── get_subtitles ──
274
+ server.registerTool("get_subtitles", {
275
+ 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).",
276
+ inputSchema: {
277
+ video: z.string().min(1).describe("YouTube video ID or URL"),
278
+ languages: z.array(z.string().min(1)).optional(),
279
+ format: z.enum(["vtt", "srt", "ttml", "srv3", "csv"]).optional(),
280
+ },
281
+ }, async ({ video, languages, format }) => {
282
+ const authErr = await requireAuth();
283
+ if (authErr)
284
+ return toolErr("AUTH_REQUIRED", authErr);
285
+ const videoId = resolveVideoInput(video);
286
+ if (!videoId)
287
+ return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
288
+ // Cookie pre-check: missing or expired → try headless refresh
289
+ if (!hasSIDCookies(PATHS.cookiesTxt) || areCookiesExpired(PATHS.cookiesTxt)) {
290
+ let refreshed = false;
291
+ try {
292
+ refreshed = await tryHeadlessRefresh();
293
+ }
294
+ catch { /* */ }
295
+ if (!refreshed || !hasSIDCookies(PATHS.cookiesTxt)) {
296
+ return toolErr("COOKIES_MISSING", "No valid YouTube cookies found.\nPlease run in your terminal: yt-mcp setup-cookies");
297
+ }
298
+ }
299
+ const usingDefaults = !languages;
300
+ const langs = languages ?? config.default_languages;
301
+ const fmt = format ?? "vtt";
302
+ const results = [];
303
+ for (let i = 0; i < langs.length; i++) {
304
+ if (i > 0) {
305
+ await randomSleep(2000, 5000);
306
+ }
307
+ const lang = langs[i];
308
+ const dl = await downloadSubtitle(videoId, lang, fmt);
309
+ if (dl.cookiesExpired) {
310
+ // Try headless auto-refresh
311
+ let refreshed = false;
312
+ try {
313
+ refreshed = await tryHeadlessRefresh();
314
+ }
315
+ catch {
316
+ // Playwright not available or browser failed — fall through
317
+ }
318
+ if (refreshed) {
319
+ // Retry the download with fresh cookies
320
+ const retry = await downloadSubtitle(videoId, lang, fmt);
321
+ if (retry.ok) {
322
+ if (retry.text) {
323
+ results.push({ language: lang, status: "ok", text: retry.text, file_path: retry.filePath });
324
+ }
325
+ else {
326
+ results.push({ language: lang, status: "ok", file_path: retry.filePath });
327
+ }
328
+ continue;
329
+ }
330
+ // Retry also failed with expired cookies — give up
331
+ }
332
+ return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired and auto-refresh failed.\n" +
333
+ "Please run in your terminal: yt-mcp setup-cookies");
334
+ }
335
+ if (!dl.ok) {
336
+ // When using default languages, silently skip unavailable ones
337
+ if (!usingDefaults) {
338
+ results.push({
339
+ language: lang,
340
+ status: "failed",
341
+ error: dl.error,
342
+ });
343
+ }
344
+ }
345
+ else if (dl.text) {
346
+ results.push({
347
+ language: lang,
348
+ status: "ok",
349
+ text: dl.text,
350
+ file_path: dl.filePath,
351
+ });
352
+ }
353
+ else {
354
+ results.push({
355
+ language: lang,
356
+ status: "ok",
357
+ file_path: dl.filePath,
358
+ });
359
+ }
360
+ }
361
+ return toolOk({
362
+ status: "completed",
363
+ video_id: videoId,
364
+ format: fmt,
365
+ results,
366
+ });
367
+ });
368
+ // ── batch_get_subtitles ──
369
+ server.registerTool("batch_get_subtitles", {
370
+ description: "Download subtitles for multiple YouTube videos. Max 10 per batch. Accepts video IDs or URLs (can mix).",
371
+ inputSchema: {
372
+ videos: z.array(z.string().min(1)).min(1).max(10).describe("Video IDs or YouTube URLs"),
373
+ languages: z.array(z.string().min(1)).optional(),
374
+ format: z.enum(["vtt", "srt", "ttml", "srv3", "csv"]).optional(),
375
+ },
376
+ }, async ({ videos, languages, format }) => {
377
+ const authErr = await requireAuth();
378
+ if (authErr)
379
+ return toolErr("AUTH_REQUIRED", authErr);
380
+ const { resolvedIds, invalidInputs } = normalizeVideoInputs(videos);
381
+ if (resolvedIds.length === 0) {
382
+ return toolErr("INVALID_INPUT", `无法解析任何视频 ID。无效输入: ${invalidInputs.join(", ")}`);
383
+ }
384
+ const langs = languages ?? config.default_languages;
385
+ const fmt = format ?? "vtt";
386
+ // Cookie pre-check: missing or expired → try headless refresh
387
+ if (!hasSIDCookies(PATHS.cookiesTxt) || areCookiesExpired(PATHS.cookiesTxt)) {
388
+ let refreshed = false;
389
+ try {
390
+ refreshed = await tryHeadlessRefresh();
391
+ }
392
+ catch { /* */ }
393
+ if (!refreshed || !hasSIDCookies(PATHS.cookiesTxt)) {
394
+ return toolErr("COOKIES_MISSING", "No valid YouTube cookies found.\nPlease run in your terminal: yt-mcp setup-cookies");
395
+ }
396
+ }
397
+ const results = [];
398
+ let succeeded = 0;
399
+ let failed = 0;
400
+ for (let v = 0; v < resolvedIds.length; v++) {
401
+ if (v > 0) {
402
+ await randomSleep(config.batch_sleep_min_ms, config.batch_sleep_max_ms);
403
+ }
404
+ const videoId = resolvedIds[v];
405
+ const langFound = [];
406
+ let lastError;
407
+ let cookiesExpired = false;
408
+ for (let l = 0; l < langs.length; l++) {
409
+ if (l > 0) {
410
+ await randomSleep(2000, 5000);
411
+ }
412
+ const dl = await downloadSubtitle(videoId, langs[l], fmt);
413
+ if (dl.cookiesExpired) {
414
+ cookiesExpired = true;
415
+ break;
416
+ }
417
+ if (dl.ok) {
418
+ langFound.push(langs[l]);
419
+ }
420
+ else {
421
+ lastError = dl.error;
422
+ }
423
+ }
424
+ if (cookiesExpired) {
425
+ // Try headless auto-refresh
426
+ let refreshed = false;
427
+ try {
428
+ refreshed = await tryHeadlessRefresh();
429
+ }
430
+ catch { /* fall through */ }
431
+ if (refreshed) {
432
+ // Retry this video's subtitles
433
+ let retrySuccess = false;
434
+ for (const lang of langs) {
435
+ const retry = await downloadSubtitle(videoId, lang, fmt);
436
+ if (retry.ok) {
437
+ langFound.push(lang);
438
+ retrySuccess = true;
439
+ }
440
+ }
441
+ if (retrySuccess) {
442
+ cookiesExpired = false;
443
+ }
444
+ }
445
+ if (cookiesExpired) {
446
+ return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired and auto-refresh failed.\n" +
447
+ "Please run in your terminal: yt-mcp setup-cookies");
448
+ }
449
+ }
450
+ if (langFound.length > 0) {
451
+ succeeded++;
452
+ results.push({
453
+ video_id: videoId,
454
+ status: "ok",
455
+ languages_found: langFound,
456
+ file_path: PATHS.subtitlesDir,
457
+ });
458
+ }
459
+ else {
460
+ failed++;
461
+ results.push({
462
+ video_id: videoId,
463
+ status: "failed",
464
+ error: lastError ?? "No subtitles found",
465
+ });
466
+ }
467
+ }
468
+ return toolOk({
469
+ status: "completed",
470
+ total: resolvedIds.length,
471
+ succeeded,
472
+ failed,
473
+ invalid_inputs: invalidInputs.length > 0 ? invalidInputs : undefined,
474
+ results,
475
+ });
476
+ });
477
+ // ── list_available_subtitles ──
478
+ server.registerTool("list_available_subtitles", {
479
+ description: "List available subtitle tracks for a YouTube video. Accepts video ID or URL.",
480
+ inputSchema: {
481
+ video: z.string().min(1).describe("YouTube video ID or URL"),
482
+ },
483
+ }, async ({ video }) => {
484
+ const authErr = await requireAuth();
485
+ if (authErr)
486
+ return toolErr("AUTH_REQUIRED", authErr);
487
+ const videoId = resolveVideoInput(video);
488
+ if (!videoId)
489
+ return toolErr("INVALID_INPUT", `无法解析视频 ID: ${video}`);
490
+ const result = await runYtDlp([
491
+ "--list-subs",
492
+ "--skip-download",
493
+ `https://www.youtube.com/watch?v=${videoId}`,
494
+ ]);
495
+ if (result.exitCode !== 0) {
496
+ return toolErr("YT_DLP_ERROR", result.stderr.slice(0, 500) || "Failed to list subtitles");
497
+ }
498
+ const output = result.stdout;
499
+ const manual = [];
500
+ const automatic = [];
501
+ let section = "none";
502
+ for (const line of output.split("\n")) {
503
+ const trimmed = line.trim();
504
+ if (trimmed.includes("Available subtitles") ||
505
+ trimmed.includes("manual subtitles")) {
506
+ section = "manual";
507
+ continue;
508
+ }
509
+ if (trimmed.includes("Available automatic captions") ||
510
+ trimmed.includes("automatic captions")) {
511
+ section = "auto";
512
+ continue;
513
+ }
514
+ if (!trimmed || trimmed.startsWith("Language") || trimmed.startsWith("---")) {
515
+ continue;
516
+ }
517
+ const langCode = trimmed.split(/\s+/)[0];
518
+ if (!langCode)
519
+ continue;
520
+ if (section === "manual")
521
+ manual.push(langCode);
522
+ else if (section === "auto")
523
+ automatic.push(langCode);
524
+ }
525
+ return toolOk({
526
+ status: "completed",
527
+ video_id: videoId,
528
+ manual,
529
+ automatic,
530
+ translatable: automatic,
531
+ });
532
+ });
533
+ // ── validate_cookies ──
534
+ server.registerTool("validate_cookies", {
535
+ description: "Validate current YouTube cookies by testing a subtitle download.",
536
+ inputSchema: {},
537
+ }, async () => {
538
+ const authErr = await requireAuth();
539
+ if (authErr)
540
+ return toolErr("AUTH_REQUIRED", authErr);
541
+ if (!existsSync(PATHS.cookiesTxt)) {
542
+ return toolOk({
543
+ valid: false,
544
+ error: "cookies.txt not found",
545
+ });
546
+ }
547
+ if (!hasSIDCookies(PATHS.cookiesTxt)) {
548
+ return toolOk({
549
+ valid: false,
550
+ error: "cookies.txt does not contain YouTube SID cookies",
551
+ });
552
+ }
553
+ // Test with a known public video (Rick Astley - Never Gonna Give You Up)
554
+ const result = await runYtDlp([
555
+ "--skip-download",
556
+ "-f", "mhtml",
557
+ "--write-auto-sub",
558
+ "--sub-langs",
559
+ "en",
560
+ "--sub-format",
561
+ "vtt",
562
+ "--output",
563
+ join(PATHS.subtitlesDir, "cookie_test"),
564
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
565
+ ]);
566
+ if (result.exitCode === 0) {
567
+ return toolOk({ valid: true });
568
+ }
569
+ const isExpired = result.stderr.includes("Sign in") ||
570
+ result.stderr.includes("cookies") ||
571
+ result.stderr.includes("login");
572
+ return toolOk({
573
+ valid: false,
574
+ error: isExpired
575
+ ? "Cookies expired"
576
+ : result.stderr.slice(0, 300),
577
+ });
578
+ });
579
+ }
@@ -0,0 +1,23 @@
1
+ export declare const PATHS: {
2
+ configDir: string;
3
+ authJson: string;
4
+ cookiesTxt: string;
5
+ browserProfile: string;
6
+ launcherJs: string;
7
+ npmCacheDir: string;
8
+ subtitlesDir: string;
9
+ configJson: string;
10
+ };
11
+ export interface YtMcpConfig {
12
+ /** Auth gateway root URL — for OAuth, PAT, introspection (e.g., https://mkterswingman.com) */
13
+ auth_url: string;
14
+ /** v2 API URL — for data tools (e.g., https://mkterswingman.com/mcp/yt) */
15
+ api_url: string;
16
+ default_languages: string[];
17
+ batch_sleep_min_ms: number;
18
+ batch_sleep_max_ms: number;
19
+ batch_max_size: number;
20
+ }
21
+ export declare function ensureConfigDir(): void;
22
+ export declare function loadConfig(): YtMcpConfig;
23
+ export declare function saveConfig(config: Partial<YtMcpConfig>): void;
@@ -0,0 +1,47 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".yt-mcp");
5
+ const SUBTITLES_DIR = join(homedir(), "Downloads", "yt-mcp");
6
+ export const PATHS = {
7
+ configDir: CONFIG_DIR,
8
+ authJson: join(CONFIG_DIR, "auth.json"),
9
+ cookiesTxt: join(CONFIG_DIR, "cookies.txt"),
10
+ browserProfile: join(CONFIG_DIR, "browser-profile"),
11
+ launcherJs: join(CONFIG_DIR, "launcher.mjs"),
12
+ npmCacheDir: join(CONFIG_DIR, "npm-cache"),
13
+ subtitlesDir: SUBTITLES_DIR,
14
+ configJson: join(CONFIG_DIR, "config.json"),
15
+ };
16
+ const DEFAULTS = {
17
+ auth_url: "https://mkterswingman.com",
18
+ api_url: "https://mkterswingman.com/mcp/yt",
19
+ // Fixed list, not probed: --list-subs costs ~10s extra and YouTube has no "source language" concept
20
+ default_languages: ["en", "zh-Hans"],
21
+ batch_sleep_min_ms: 3000,
22
+ batch_sleep_max_ms: 8000,
23
+ batch_max_size: 10,
24
+ };
25
+ export function ensureConfigDir() {
26
+ mkdirSync(CONFIG_DIR, { recursive: true });
27
+ }
28
+ export function loadConfig() {
29
+ ensureConfigDir();
30
+ if (!existsSync(PATHS.configJson)) {
31
+ return { ...DEFAULTS };
32
+ }
33
+ try {
34
+ const raw = readFileSync(PATHS.configJson, "utf8");
35
+ const parsed = JSON.parse(raw);
36
+ return { ...DEFAULTS, ...parsed };
37
+ }
38
+ catch {
39
+ return { ...DEFAULTS };
40
+ }
41
+ }
42
+ export function saveConfig(config) {
43
+ ensureConfigDir();
44
+ const existing = loadConfig();
45
+ const merged = { ...existing, ...config };
46
+ writeFileSync(PATHS.configJson, JSON.stringify(merged, null, 2), "utf8");
47
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Headless cookie auto-refresh.
3
+ *
4
+ * Uses Playwright to open YouTube in a headless browser with the existing
5
+ * browser-profile. If Google session is still valid, YouTube cookies are
6
+ * extracted and saved automatically — no user interaction needed.
7
+ *
8
+ * If Google session has expired too, returns false so the caller can
9
+ * prompt the user for interactive login.
10
+ */
11
+ /**
12
+ * Attempt to refresh YouTube cookies headlessly using existing browser profile.
13
+ *
14
+ * @returns true if cookies were refreshed, false if Google session expired
15
+ * (needs interactive login).
16
+ * @throws if Playwright is not installed or browser can't launch.
17
+ */
18
+ export declare function tryHeadlessRefresh(): Promise<boolean>;