@mkterswingman/yt-mcp 0.3.0 → 0.3.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.
@@ -1 +1,34 @@
1
+ export type PlaywrightCookie = {
2
+ name: string;
3
+ value: string;
4
+ domain: string;
5
+ path: string;
6
+ secure: boolean;
7
+ httpOnly: boolean;
8
+ expires: number;
9
+ };
10
+ /**
11
+ * Detect which browser channel is available on the system.
12
+ * Prefers Chrome → Edge → falls back to bundled Chromium.
13
+ */
14
+ export declare function detectBrowserChannel(chromium: typeof import("playwright").chromium): Promise<string>;
15
+ export declare const CHANNEL_LABELS: Record<string, string>;
16
+ /** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
17
+ export declare function hasYouTubeSession(cookies: Array<{
18
+ name: string;
19
+ domain: string;
20
+ }>): boolean;
21
+ /**
22
+ * Save cookies to Netscape format file and close the browser context.
23
+ */
24
+ export declare function saveCookiesAndClose(context: {
25
+ close(): Promise<void>;
26
+ }, rawCookies: PlaywrightCookie[], silent?: boolean): Promise<void>;
27
+ /**
28
+ * Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
29
+ */
30
+ export declare function saveCookiesToDisk(rawCookies: PlaywrightCookie[]): void;
31
+ /**
32
+ * Interactive cookie setup — opens a visible browser for user to log in.
33
+ */
1
34
  export declare function runSetupCookies(): Promise<void>;
@@ -5,7 +5,7 @@ import { cookiesToNetscape } from "../utils/cookies.js";
5
5
  * Detect which browser channel is available on the system.
6
6
  * Prefers Chrome → Edge → falls back to bundled Chromium.
7
7
  */
8
- async function detectBrowserChannel(chromium) {
8
+ export async function detectBrowserChannel(chromium) {
9
9
  for (const channel of ["chrome", "msedge"]) {
10
10
  try {
11
11
  const browser = await chromium.launch({ channel, headless: true });
@@ -18,16 +18,57 @@ async function detectBrowserChannel(chromium) {
18
18
  }
19
19
  return "chromium";
20
20
  }
21
- const CHANNEL_LABELS = {
21
+ export const CHANNEL_LABELS = {
22
22
  chrome: "Google Chrome",
23
23
  msedge: "Microsoft Edge",
24
24
  chromium: "Playwright Chromium",
25
25
  };
26
26
  /** Check if YouTube SID cookies are present — the real signal of a logged-in session. */
27
- function hasYouTubeSession(cookies) {
27
+ export function hasYouTubeSession(cookies) {
28
28
  return cookies.some((c) => (c.name === "SID" || c.name === "HSID" || c.name === "SSID") &&
29
29
  c.domain.includes("youtube.com"));
30
30
  }
31
+ /**
32
+ * Save cookies to Netscape format file and close the browser context.
33
+ */
34
+ export async function saveCookiesAndClose(context, rawCookies, silent = false) {
35
+ try {
36
+ await context.close();
37
+ }
38
+ catch { /* already closed */ }
39
+ const entries = rawCookies.map((c) => ({
40
+ name: c.name,
41
+ value: c.value,
42
+ domain: c.domain,
43
+ path: c.path,
44
+ secure: c.secure,
45
+ httpOnly: c.httpOnly,
46
+ expires: c.expires,
47
+ }));
48
+ const netscape = cookiesToNetscape(entries);
49
+ writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
50
+ if (!silent) {
51
+ const httpOnlyCount = entries.filter((c) => c.httpOnly).length;
52
+ console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
53
+ console.log(` ${entries.length} cookies extracted (${httpOnlyCount} httpOnly)\n`);
54
+ }
55
+ }
56
+ /**
57
+ * Save cookies to disk WITHOUT closing the context (caller manages lifecycle).
58
+ */
59
+ export function saveCookiesToDisk(rawCookies) {
60
+ const entries = rawCookies.map((c) => ({
61
+ name: c.name,
62
+ value: c.value,
63
+ domain: c.domain,
64
+ path: c.path,
65
+ secure: c.secure,
66
+ httpOnly: c.httpOnly,
67
+ expires: c.expires,
68
+ }));
69
+ const netscape = cookiesToNetscape(entries);
70
+ writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
71
+ }
31
72
  /**
32
73
  * Poll cookies at intervals until YouTube session cookies appear or timeout.
33
74
  * Returns the full cookie list on success, or null on timeout / browser closed.
@@ -35,30 +76,28 @@ function hasYouTubeSession(cookies) {
35
76
  async function waitForLogin(context, isClosed, timeoutMs, pollIntervalMs = 2000) {
36
77
  const deadline = Date.now() + timeoutMs;
37
78
  while (Date.now() < deadline) {
38
- // Detect if user closed the browser / all pages
39
- if (isClosed()) {
79
+ if (isClosed())
40
80
  return null;
41
- }
42
81
  try {
43
82
  const cookies = await context.cookies("https://www.youtube.com");
44
83
  if (hasYouTubeSession(cookies)) {
45
- // Give a moment for all cookies to settle after login redirect
46
84
  await new Promise((r) => setTimeout(r, 2000));
47
85
  return await context.cookies("https://www.youtube.com");
48
86
  }
49
87
  }
50
88
  catch {
51
- // Browser was closed by user — bail out
52
89
  return null;
53
90
  }
54
91
  await new Promise((r) => setTimeout(r, pollIntervalMs));
55
92
  }
56
- return null; // timeout
93
+ return null;
57
94
  }
95
+ /**
96
+ * Interactive cookie setup — opens a visible browser for user to log in.
97
+ */
58
98
  export async function runSetupCookies() {
59
99
  console.log("\n🍪 YouTube Cookie Setup\n");
60
100
  ensureConfigDir();
61
- // Dynamic import of playwright to avoid pulling it in at serve time
62
101
  let chromium;
63
102
  try {
64
103
  const pw = await import("playwright");
@@ -88,20 +127,14 @@ export async function runSetupCookies() {
88
127
  }
89
128
  throw err;
90
129
  }
91
- // Graceful handling when user closes browser manually
92
130
  let browserClosed = false;
93
- context.on("close", () => {
94
- browserClosed = true;
95
- });
131
+ context.on("close", () => { browserClosed = true; });
96
132
  const page = context.pages()[0] ?? (await context.newPage());
97
- // Also detect when user closes the page/window (not just the whole context)
98
133
  page.on("close", () => {
99
- // If no more pages remain, treat as browser closed
100
134
  if (context.pages().length === 0) {
101
135
  browserClosed = true;
102
136
  }
103
137
  });
104
- // Navigate to YouTube first — familiar page for the user
105
138
  try {
106
139
  await page.goto("https://www.youtube.com");
107
140
  await page.waitForLoadState("domcontentloaded");
@@ -113,14 +146,12 @@ export async function runSetupCookies() {
113
146
  }
114
147
  throw new Error("Failed to navigate to YouTube");
115
148
  }
116
- // Check if already logged in (e.g., from a previous session in this profile)
117
149
  const existingCookies = await context.cookies("https://www.youtube.com");
118
150
  if (hasYouTubeSession(existingCookies)) {
119
151
  console.log("✅ Already logged in to YouTube!\n");
120
152
  await saveCookiesAndClose(context, existingCookies);
121
153
  return;
122
154
  }
123
- // Try to click the "Sign in" button on YouTube homepage
124
155
  try {
125
156
  const signInBtn = page.locator('a[href*="accounts.google.com/ServiceLogin"], ' +
126
157
  'tp-yt-paper-button#button:has-text("Sign in"), ' +
@@ -131,7 +162,6 @@ export async function runSetupCookies() {
131
162
  console.log("🔑 Opened sign-in page. Please log in to your Google account.\n");
132
163
  }
133
164
  catch {
134
- // Couldn't find sign-in button — navigate directly
135
165
  try {
136
166
  await page.goto("https://accounts.google.com/ServiceLogin?continue=https://www.youtube.com");
137
167
  console.log("🔑 Please log in to your Google account in the browser window.\n");
@@ -144,7 +174,7 @@ export async function runSetupCookies() {
144
174
  throw new Error("Failed to navigate to Google login");
145
175
  }
146
176
  }
147
- const LOGIN_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
177
+ const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
148
178
  console.log("⏳ Waiting for login (up to 2 minutes)...");
149
179
  console.log(" Login will be detected automatically once you sign in.\n");
150
180
  const finalCookies = await waitForLogin(context, () => browserClosed, LOGIN_TIMEOUT_MS);
@@ -154,7 +184,6 @@ export async function runSetupCookies() {
154
184
  return;
155
185
  }
156
186
  if (!finalCookies) {
157
- // Timeout — clean up
158
187
  try {
159
188
  await context.close();
160
189
  }
@@ -165,23 +194,3 @@ export async function runSetupCookies() {
165
194
  }
166
195
  await saveCookiesAndClose(context, finalCookies);
167
196
  }
168
- async function saveCookiesAndClose(context, rawCookies) {
169
- try {
170
- await context.close();
171
- }
172
- catch { /* already closed */ }
173
- const entries = rawCookies.map((c) => ({
174
- name: c.name,
175
- value: c.value,
176
- domain: c.domain,
177
- path: c.path,
178
- secure: c.secure,
179
- httpOnly: c.httpOnly,
180
- expires: c.expires,
181
- }));
182
- const netscape = cookiesToNetscape(entries);
183
- writeFileSync(PATHS.cookiesTxt, netscape, { mode: 0o600 });
184
- const httpOnlyCount = entries.filter((c) => c.httpOnly).length;
185
- console.log(`✅ Cookies saved to ${PATHS.cookiesTxt}`);
186
- console.log(` ${entries.length} cookies extracted (${httpOnlyCount} httpOnly)\n`);
187
- }
@@ -3,7 +3,8 @@ 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 } from "../utils/cookies.js";
6
+ import { hasSIDCookies, areCookiesExpired } from "../utils/cookies.js";
7
+ import { tryHeadlessRefresh } from "../utils/cookieRefresh.js";
7
8
  import { resolveVideoInput, normalizeVideoInputs } from "../utils/videoInput.js";
8
9
  const AUTH_REQUIRED_MSG = "❌ 未认证。请先登录:\n• OAuth: npx @mkterswingman/yt-mcp setup\n• PAT: 设置环境变量 YT_MCP_TOKEN 或在 https://mkterswingman.com/pat/login 生成 token";
9
10
  function toolOk(payload) {
@@ -284,6 +285,17 @@ export function registerSubtitleTools(server, config, tokenManager) {
284
285
  const videoId = resolveVideoInput(video);
285
286
  if (!videoId)
286
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
+ }
287
299
  const usingDefaults = !languages;
288
300
  const langs = languages ?? config.default_languages;
289
301
  const fmt = format ?? "vtt";
@@ -295,7 +307,30 @@ export function registerSubtitleTools(server, config, tokenManager) {
295
307
  const lang = langs[i];
296
308
  const dl = await downloadSubtitle(videoId, lang, fmt);
297
309
  if (dl.cookiesExpired) {
298
- return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired. Run: npx @mkterswingman/yt-mcp setup-cookies");
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");
299
334
  }
300
335
  if (!dl.ok) {
301
336
  // When using default languages, silently skip unavailable ones
@@ -348,9 +383,16 @@ export function registerSubtitleTools(server, config, tokenManager) {
348
383
  }
349
384
  const langs = languages ?? config.default_languages;
350
385
  const fmt = format ?? "vtt";
351
- // Cookie pre-check
352
- if (!hasSIDCookies(PATHS.cookiesTxt)) {
353
- return toolErr("COOKIES_MISSING", "No valid YouTube cookies found. Run: npx @mkterswingman/yt-mcp setup-cookies");
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
+ }
354
396
  }
355
397
  const results = [];
356
398
  let succeeded = 0;
@@ -380,7 +422,30 @@ export function registerSubtitleTools(server, config, tokenManager) {
380
422
  }
381
423
  }
382
424
  if (cookiesExpired) {
383
- return toolErr("COOKIES_EXPIRED", "YouTube cookies have expired. Run: npx @mkterswingman/yt-mcp setup-cookies");
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
+ }
384
449
  }
385
450
  if (langFound.length > 0) {
386
451
  succeeded++;
@@ -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>;
@@ -0,0 +1,70 @@
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
+ import { existsSync } from "node:fs";
12
+ import { PATHS, ensureConfigDir } from "./config.js";
13
+ import { detectBrowserChannel, hasYouTubeSession, saveCookiesToDisk, } from "../cli/setupCookies.js";
14
+ const HEADLESS_TIMEOUT_MS = 30_000;
15
+ /**
16
+ * Attempt to refresh YouTube cookies headlessly using existing browser profile.
17
+ *
18
+ * @returns true if cookies were refreshed, false if Google session expired
19
+ * (needs interactive login).
20
+ * @throws if Playwright is not installed or browser can't launch.
21
+ */
22
+ export async function tryHeadlessRefresh() {
23
+ // No browser profile = never logged in = can't refresh
24
+ if (!existsSync(PATHS.browserProfile)) {
25
+ return false;
26
+ }
27
+ ensureConfigDir();
28
+ // Dynamic import to keep serve startup fast
29
+ let chromium;
30
+ try {
31
+ const pw = await import("playwright");
32
+ chromium = pw.chromium;
33
+ }
34
+ catch {
35
+ return false; // Playwright not installed — can't refresh
36
+ }
37
+ const channel = await detectBrowserChannel(chromium);
38
+ let context;
39
+ try {
40
+ context = await chromium.launchPersistentContext(PATHS.browserProfile, {
41
+ headless: true,
42
+ channel,
43
+ args: ["--disable-blink-features=AutomationControlled"],
44
+ });
45
+ }
46
+ catch {
47
+ return false; // Browser launch failed
48
+ }
49
+ try {
50
+ const page = context.pages()[0] ?? (await context.newPage());
51
+ await page.goto("https://www.youtube.com", { timeout: HEADLESS_TIMEOUT_MS });
52
+ await page.waitForLoadState("domcontentloaded");
53
+ const cookies = await context.cookies("https://www.youtube.com");
54
+ if (!hasYouTubeSession(cookies)) {
55
+ // Google session expired — can't auto-refresh
56
+ await context.close().catch(() => { });
57
+ return false;
58
+ }
59
+ // Wait a moment for all cookies to settle
60
+ await new Promise((r) => setTimeout(r, 1500));
61
+ const finalCookies = await context.cookies("https://www.youtube.com");
62
+ saveCookiesToDisk(finalCookies);
63
+ await context.close().catch(() => { });
64
+ return true;
65
+ }
66
+ catch {
67
+ await context.close().catch(() => { });
68
+ return false;
69
+ }
70
+ }
@@ -9,3 +9,10 @@ export interface CookieEntry {
9
9
  }
10
10
  export declare function cookiesToNetscape(cookies: CookieEntry[]): string;
11
11
  export declare function hasSIDCookies(cookiesPath: string): boolean;
12
+ /**
13
+ * Check if YouTube session cookies have expired by parsing Netscape cookie
14
+ * file timestamps (field 5, Unix epoch seconds).
15
+ *
16
+ * Returns true if: file missing, no session cookies, or ALL session cookies expired.
17
+ */
18
+ export declare function areCookiesExpired(cookiesPath: string): boolean;
@@ -26,3 +26,53 @@ export function hasSIDCookies(cookiesPath) {
26
26
  return false;
27
27
  }
28
28
  }
29
+ /**
30
+ * Cookie names that indicate a valid YouTube session.
31
+ * If at least one of these is present and not expired, cookies are valid.
32
+ */
33
+ const SESSION_COOKIE_PATTERNS = [
34
+ "SID", "HSID", "SSID", "APISID", "SAPISID",
35
+ "__Secure-1PSID", "__Secure-3PSID",
36
+ "__Secure-1PSIDTS", "__Secure-3PSIDTS",
37
+ "LOGIN_INFO",
38
+ ];
39
+ /**
40
+ * Check if YouTube session cookies have expired by parsing Netscape cookie
41
+ * file timestamps (field 5, Unix epoch seconds).
42
+ *
43
+ * Returns true if: file missing, no session cookies, or ALL session cookies expired.
44
+ */
45
+ export function areCookiesExpired(cookiesPath) {
46
+ if (!existsSync(cookiesPath))
47
+ return true;
48
+ try {
49
+ const content = readFileSync(cookiesPath, "utf8");
50
+ const nowSec = Date.now() / 1000;
51
+ let foundAnySession = false;
52
+ let foundAnyValid = false;
53
+ for (const line of content.split("\n")) {
54
+ if (line.startsWith("#") || !line.trim())
55
+ continue;
56
+ const fields = line.split("\t");
57
+ if (fields.length < 7)
58
+ continue;
59
+ const name = fields[5];
60
+ if (!SESSION_COOKIE_PATTERNS.includes(name))
61
+ continue;
62
+ foundAnySession = true;
63
+ const expiry = Number(fields[4]);
64
+ // expiry 0 = session cookie (no expiration) — treat as valid
65
+ if (expiry === 0 || expiry >= nowSec) {
66
+ foundAnyValid = true;
67
+ break; // At least one valid session cookie — not expired
68
+ }
69
+ }
70
+ // No session cookies at all = need login; all expired = need refresh
71
+ if (!foundAnySession)
72
+ return true;
73
+ return !foundAnyValid;
74
+ }
75
+ catch {
76
+ return true;
77
+ }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mkterswingman/yt-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "YouTube MCP client — local subtitles + remote API proxy",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,10 +17,10 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@modelcontextprotocol/sdk": "^1.27.1",
20
+ "playwright": "^1.58.0",
20
21
  "zod": "^4.3.6"
21
22
  },
22
23
  "optionalDependencies": {
23
- "playwright": "^1.58.0",
24
24
  "puppeteer-core": "^24.0.0"
25
25
  },
26
26
  "keywords": [